ValueOn Lead to Opportunity durchgespielt, bugfixes im datapicker und node hadover

This commit is contained in:
Ida 2026-05-03 18:02:44 +02:00
parent 1d2d247273
commit 3d580a5fca
8 changed files with 500 additions and 106 deletions

View file

@ -30,6 +30,8 @@ export interface PortField {
description: string | Record<string, string>; description: string | Record<string, string>;
required: boolean; required: boolean;
enumValues?: string[] | null; enumValues?: string[] | null;
/** When true, surface at the top of the DataPicker as the most common/recommended pick. */
recommended?: boolean;
} }
export interface PortSchema { export interface PortSchema {

View file

@ -1725,6 +1725,35 @@
margin-left: 4px; margin-left: 4px;
} }
/* Type-mismatch warning badge (⚠) — shown instead of hiding incompatible fields. */
.dataPickerMismatchBadge {
font-size: 10px;
margin-left: 4px;
color: var(--color-warning, #f59e0b);
flex-shrink: 0;
}
/* Recommended pick: subtle highlight on the row */
.dataPickerLeafRecommended {
font-weight: 500;
}
/* "Empfohlen" pill shown on recommended entries */
.dataPickerRecommendedPill {
display: inline-block;
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
padding: 1px 5px;
border-radius: 10px;
margin-left: 5px;
background: var(--color-primary-light, #dbeafe);
color: var(--color-primary, #2563eb);
flex-shrink: 0;
vertical-align: middle;
}
/* "iterieren" affordance visually distinct (subtle accent), readable on /* "iterieren" affordance visually distinct (subtle accent), readable on
* the picker's white background and on the leaf's blue hover background. */ * the picker's white background and on the leaf's blue hover background. */
.dataPickerIterateBtn { .dataPickerIterateBtn {

View file

@ -0,0 +1,181 @@
/**
* ContextBuilderRenderer multi-select context binding for AI nodes.
*
* Renders a list of DataRef entries (each pointing to an upstream node's output
* path). On execution the backend serialises each ref, joins them with double
* newlines and prepends the result to the AI prompt.
*
* Stored value shape:
* [ { type: "ref", nodeId: "...", path: [...], expectedType: "..." }, ]
*/
import React from 'react';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { DataPicker } from '../shared/DataPicker';
import { isRef, type DataRef, type SystemVarRef } from '../shared/dataRef';
import type { FieldRendererProps } from './index';
function isRefEntry(v: unknown): v is DataRef {
return isRef(v);
}
function toRefList(raw: unknown): DataRef[] {
if (!raw) return [];
if (Array.isArray(raw)) return raw.filter(isRefEntry);
if (isRefEntry(raw)) return [raw];
return [];
}
const CHIP_STYLE: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '3px 6px 3px 10px',
background: '#eaf6e8',
border: '1px solid #5cb85c',
borderRadius: 4,
fontSize: 12,
marginBottom: 4,
};
const REMOVE_BTN: React.CSSProperties = {
padding: '0 5px',
border: '1px solid #5cb85c',
borderRadius: 3,
background: '#fff',
color: '#3c763d',
cursor: 'pointer',
fontSize: 11,
marginLeft: 'auto',
};
export const ContextBuilderRenderer: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage();
const dataFlow = useAutomation2DataFlow();
const [pickerOpen, setPickerOpen] = React.useState(false);
const dragIndex = React.useRef<number | null>(null);
const entries = toRefList(value);
const sourceIds = dataFlow?.getAvailableSourceIds() ?? [];
const hasSources = sourceIds.some((id) => {
const n = dataFlow?.nodes.find((x) => x.id === id);
return n?.type !== 'trigger.manual';
});
const getRefLabel = (ref: DataRef): string => {
const nodeLabel =
dataFlow?.getNodeLabel(
dataFlow.nodes.find((n) => n.id === ref.nodeId) ?? { id: ref.nodeId },
) ?? ref.nodeId;
const pathStr = ref.path.length > 0 ? ref.path.map(String).join('.') : null;
return pathStr ? `${nodeLabel}${pathStr}` : nodeLabel;
};
const addRef = (picked: DataRef | SystemVarRef) => {
if (!isRefEntry(picked)) return;
const alreadyIn = entries.some(
(e) => e.nodeId === picked.nodeId && e.path.join('.') === picked.path.join('.'),
);
if (!alreadyIn) {
onChange([...entries, picked]);
}
setPickerOpen(false);
};
const removeRef = (index: number) => {
const next = entries.filter((_, i) => i !== index);
onChange(next.length ? next : undefined);
};
const moveRef = (fromIndex: number, toIndex: number) => {
if (fromIndex === toIndex) return;
const next = [...entries];
const [moved] = next.splice(fromIndex, 1);
next.splice(toIndex, 0, moved);
onChange(next);
};
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 4, fontWeight: 600 }}>
{param.description || param.name}
{param.required && <span style={{ color: '#d9534f', marginLeft: 4 }}>*</span>}
</label>
{entries.length > 0 && (
<div style={{ marginBottom: 4 }}>
{entries.map((ref, i) => (
<div
key={`${ref.nodeId}-${ref.path.join('.')}`}
style={{ ...CHIP_STYLE, cursor: 'grab' }}
draggable
onDragStart={() => { dragIndex.current = i; }}
onDragOver={(e) => { e.preventDefault(); }}
onDrop={() => {
if (dragIndex.current != null) moveRef(dragIndex.current, i);
dragIndex.current = null;
}}
onDragEnd={() => { dragIndex.current = null; }}
>
<span style={{ flex: 1, color: '#2d6a2d' }}>
{getRefLabel(ref)}
</span>
<button type="button" style={REMOVE_BTN} onClick={() => removeRef(i)} title={t('Entfernen')}>
×
</button>
</div>
))}
</div>
)}
{entries.length === 0 && (
<div
style={{
padding: '4px 8px',
background: '#f8f8f8',
border: '1px dashed #ccc',
borderRadius: 4,
fontSize: 11,
color: '#888',
marginBottom: 4,
}}
>
{t('Noch keine Quellen gewählt — wähle Daten aus vorherigen Schritten.')}
</div>
)}
<button
type="button"
onClick={() => setPickerOpen(true)}
disabled={!hasSources}
style={{
width: '100%',
padding: '4px 8px',
borderRadius: 4,
border: `1px solid #1c5fb5`,
background: hasSources ? '#fff' : '#f5f5f5',
color: hasSources ? '#1c5fb5' : '#999',
cursor: hasSources ? 'pointer' : 'not-allowed',
fontSize: 12,
textAlign: 'left',
}}
>
{hasSources ? t('+ Datenquelle hinzufügen …') : t('Keine vorherigen Nodes verfügbar')}
</button>
{dataFlow && (
<DataPicker
open={pickerOpen}
onClose={() => setPickerOpen(false)}
onPick={addRef}
availableSourceIds={sourceIds}
nodes={dataFlow.nodes}
nodeOutputsPreview={dataFlow.nodeOutputsPreview}
getNodeLabel={dataFlow.getNodeLabel}
expectedParamType={param.type}
/>
)}
</div>
);
};

View file

@ -32,6 +32,7 @@ import { toApiGraph } from '../shared/graphUtils';
import { postUpstreamPaths } from '../../../../api/workflowApi'; import { postUpstreamPaths } from '../../../../api/workflowApi';
import type { CanvasNode } from '../../editor/FlowCanvas'; import type { CanvasNode } from '../../editor/FlowCanvas';
import { DataRefRenderer } from './DataRefRenderer'; import { DataRefRenderer } from './DataRefRenderer';
import { ContextBuilderRenderer } from './ContextBuilderRenderer';
import { FeatureInstancePicker } from './FeatureInstancePicker'; import { FeatureInstancePicker } from './FeatureInstancePicker';
import { TemplateTextareaRenderer } from './TemplateTextareaRenderer'; import { TemplateTextareaRenderer } from './TemplateTextareaRenderer';
import { getApiBaseUrl } from '../../../../../config/config'; import { getApiBaseUrl } from '../../../../../config/config';
@ -547,62 +548,98 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
next[idx] = { ...(next[idx] as Record<string, unknown>), [field]: val }; next[idx] = { ...(next[idx] as Record<string, unknown>), [field]: val };
onChange(next); onChange(next);
}; };
const inputStyle: React.CSSProperties = {
width: '100%', padding: '5px 7px', borderRadius: 4, border: '1px solid #ddd',
fontSize: 12, boxSizing: 'border-box', background: '#fff',
};
const selectStyle: React.CSSProperties = { ...inputStyle };
return ( return (
<div style={{ marginBottom: 8 }}> <div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label> <label style={{ display: 'block', fontSize: 12, marginBottom: 4, fontWeight: 600 }}>{param.description || param.name}</label>
{fields.map((f: Record<string, unknown>, i: number) => ( {fields.map((f: Record<string, unknown>, i: number) => (
<div key={i} style={{ display: 'flex', gap: 4, marginBottom: 4, alignItems: 'center' }}> <div key={i} style={{ background: '#f9f9f9', border: '1px solid #e0e0e0', borderRadius: 6, padding: '8px 10px', marginBottom: 6 }}>
<input type="text" placeholder={t('Name')} value={String(f.name ?? '')} onChange={(e) => updateField(i, 'name', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} /> {/* Row 1: Bezeichnung + delete */}
<select value={String(f.type ?? 'text')} onChange={(e) => updateField(i, 'type', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }}> <div style={{ display: 'flex', gap: 6, marginBottom: 6, alignItems: 'center' }}>
{fieldTypeOptions.map((ft) => ( <input
<option key={ft.id} value={ft.id}>{t(ft.label)}</option> type="text"
))} placeholder={t('Bezeichnung (Anzeigename)')}
<option value="group">{t('Gruppe')}</option> value={String(f.label ?? '')}
</select> onChange={(e) => updateField(i, 'label', e.target.value)}
<input type="text" placeholder={t('Bezeichnung')} value={String(f.label ?? '')} onChange={(e) => updateField(i, 'label', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} /> style={{ ...inputStyle, flex: 1, fontWeight: 500 }}
<label style={{ fontSize: 11, display: 'flex', alignItems: 'center', gap: 2 }}> />
<input type="checkbox" checked={Boolean(f.required)} onChange={(e) => updateField(i, 'required', e.target.checked)} /> {t('Pflicht')} <button
</label> type="button"
<button onClick={() => removeField(i)} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer' }}>×</button> onClick={() => removeField(i)}
title={t('Feld entfernen')}
style={{ padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', background: '#fff', color: '#999', fontSize: 13, lineHeight: 1, flexShrink: 0 }}
>×</button>
</div>
{/* Row 2: Name + Typ + Pflicht */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr auto', gap: 6, alignItems: 'end' }}>
<div>
<div style={{ fontSize: 10, color: '#888', marginBottom: 2 }}>Name (intern)</div>
<input
type="text"
placeholder="z.B. customerName"
value={String(f.name ?? '')}
onChange={(e) => updateField(i, 'name', e.target.value)}
style={inputStyle}
/>
</div>
<div>
<div style={{ fontSize: 10, color: '#888', marginBottom: 2 }}>Typ</div>
<select value={String(f.type ?? 'text')} onChange={(e) => updateField(i, 'type', e.target.value)} style={selectStyle}>
{fieldTypeOptions.map((ft) => (
<option key={ft.id} value={ft.id}>{t(ft.label)}</option>
))}
<option value="group">{t('Gruppe')}</option>
</select>
</div>
<label style={{ fontSize: 11, display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer', paddingBottom: 5, whiteSpace: 'nowrap' }}>
<input type="checkbox" checked={Boolean(f.required)} onChange={(e) => updateField(i, 'required', e.target.checked)} />
Pflicht
</label>
</div>
{String(f.type) === 'group' && ( {String(f.type) === 'group' && (
<div style={{ width: '100%', marginTop: 6, marginLeft: 8, borderLeft: '2px solid #ddd', paddingLeft: 8 }}> <div style={{ marginTop: 8, borderTop: '1px solid #e0e0e0', paddingTop: 8 }}>
<div style={{ fontSize: 11, color: '#666', marginBottom: 4 }}>{t('Unterfelder')}</div> <div style={{ fontSize: 11, color: '#666', marginBottom: 6, fontWeight: 600 }}>{t('Unterfelder')}</div>
{(Array.isArray(f.fields) ? f.fields : []).map((sub: Record<string, unknown>, j: number) => ( {(Array.isArray(f.fields) ? f.fields : []).map((sub: Record<string, unknown>, j: number) => (
<div key={j} style={{ display: 'flex', gap: 4, marginBottom: 4, flexWrap: 'wrap' }}> <div key={j} style={{ background: '#fff', border: '1px solid #e8e8e8', borderRadius: 4, padding: '6px 8px', marginBottom: 4 }}>
<input <div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
type="text" <input
placeholder={t('Name')} type="text"
value={String(sub.name ?? '')} placeholder={t('Name')}
onChange={(e) => { value={String(sub.name ?? '')}
const nextFields = [...(Array.isArray(f.fields) ? f.fields : [])]; onChange={(e) => {
nextFields[j] = { ...sub, name: e.target.value }; const nextFields = [...(Array.isArray(f.fields) ? f.fields : [])];
updateField(i, 'fields', nextFields); nextFields[j] = { ...sub, name: e.target.value };
}} updateField(i, 'fields', nextFields);
style={{ flex: 1, minWidth: 80, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} }}
/> style={{ ...inputStyle, flex: 1 }}
<select />
value={String(sub.type ?? 'text')} <select
onChange={(e) => { value={String(sub.type ?? 'text')}
const nextFields = [...(Array.isArray(f.fields) ? f.fields : [])]; onChange={(e) => {
nextFields[j] = { ...sub, type: e.target.value }; const nextFields = [...(Array.isArray(f.fields) ? f.fields : [])];
updateField(i, 'fields', nextFields); nextFields[j] = { ...sub, type: e.target.value };
}} updateField(i, 'fields', nextFields);
style={{ flex: 1, minWidth: 80, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} }}
> style={{ ...selectStyle, flex: 1 }}
{fieldTypeOptions.map((ft) => ( >
<option key={ft.id} value={ft.id}>{t(ft.label)}</option> {fieldTypeOptions.map((ft) => (
))} <option key={ft.id} value={ft.id}>{t(ft.label)}</option>
</select> ))}
<button </select>
type="button" <button
onClick={() => { type="button"
const nextFields = (Array.isArray(f.fields) ? f.fields : []).filter((_: unknown, k: number) => k !== j); onClick={() => {
updateField(i, 'fields', nextFields); const nextFields = (Array.isArray(f.fields) ? f.fields : []).filter((_: unknown, k: number) => k !== j);
}} updateField(i, 'fields', nextFields);
style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer' }} }}
> style={{ padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', background: '#fff', color: '#999', flexShrink: 0 }}
× >×</button>
</button> </div>
</div> </div>
))} ))}
<button <button
@ -611,15 +648,21 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
const nextFields = [...(Array.isArray(f.fields) ? f.fields : []), { name: '', type: 'text', label: '', required: false }]; const nextFields = [...(Array.isArray(f.fields) ? f.fields : []), { name: '', type: 'text', label: '', required: false }];
updateField(i, 'fields', nextFields); updateField(i, 'fields', nextFields);
}} }}
style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', fontSize: 11 }} style={{ marginTop: 4, padding: '3px 10px', borderRadius: 4, border: '1px dashed #bbb', cursor: 'pointer', fontSize: 11, background: '#fff', color: '#666' }}
> >
{t('Unterfeld hinzufügen')} + {t('Unterfeld hinzufügen')}
</button> </button>
</div> </div>
)} )}
</div> </div>
))} ))}
<button onClick={addField} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', fontSize: 12 }}>{t('Feld hinzufügen')}</button> <button
type="button"
onClick={addField}
style={{ width: '100%', padding: '6px', borderRadius: 4, border: '1px dashed #bbb', cursor: 'pointer', fontSize: 12, background: '#fff', color: '#555' }}
>
+ {t('Feld hinzufügen')}
</button>
</div> </div>
); );
}; };
@ -869,6 +912,7 @@ export const FRONTEND_TYPE_RENDERERS: Record<string, FieldRendererComponent> = {
file: TextInput, file: TextInput,
hidden: HiddenInput, hidden: HiddenInput,
dataRef: DataRefRenderer, dataRef: DataRefRenderer,
contextBuilder: ContextBuilderRenderer,
userConnection: ConnectionPicker, userConnection: ConnectionPicker,
featureInstance: FeatureInstancePicker, featureInstance: FeatureInstancePicker,
sharepointFolder: SharepointPathPicker, sharepointFolder: SharepointPathPicker,

View file

@ -35,6 +35,10 @@ interface PickablePath {
/** True iff this path produces `List[X]` and the consumer expects `X` /** True iff this path produces `List[X]` and the consumer expects `X`
* picking with iterate=true appends the wildcard segment. */ * picking with iterate=true appends the wildcard segment. */
iterable?: boolean; iterable?: boolean;
/** Annotated after strict-filter pass: type exists but doesn't match the expected param type. */
typeMismatch?: boolean;
/** Surfaced at the top of the list as the most common / recommended pick. */
recommended?: boolean;
} }
const _LIST_INNER_RE = /^List\[(.+)\]$/; const _LIST_INNER_RE = /^List\[(.+)\]$/;
@ -47,10 +51,22 @@ function _buildPathsFromSchema(
): PickablePath[] { ): PickablePath[] {
if (!schema || !schema.fields || depth > 8) return []; if (!schema || !schema.fields || depth > 8) return [];
const result: PickablePath[] = []; const result: PickablePath[] = [];
// For form schemas (kind=fromGraph), expose the whole `payload` object as a
// top-level pickable entry so the user can pass the entire form at once.
if (depth === 0 && schema.name?.startsWith('FormPayload')) {
result.push({
path: ['payload'],
label: 'Gesamtes Formular',
type: 'object',
recommended: true,
});
}
for (const field of schema.fields) { for (const field of schema.fields) {
const fieldPath = [...basePath, field.name]; const fieldPath = [...basePath, field.name];
const label = fieldPath.map(String).join(' → '); const label = fieldPath.map(String).join(' → ');
result.push({ path: fieldPath, label, type: field.type }); result.push({ path: fieldPath, label, type: field.type, recommended: field.recommended ?? false });
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]) {
@ -326,15 +342,15 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
<div key={loopId} style={{ marginBottom: 6 }}> <div key={loopId} style={{ marginBottom: 6 }}>
<div style={{ fontSize: 11, color: 'var(--text-secondary)', marginBottom: 2 }}>{loopLabel}</div> <div style={{ fontSize: 11, color: 'var(--text-secondary)', marginBottom: 2 }}>{loopLabel}</div>
{loopPaths.map((p, i) => { {loopPaths.map((p, i) => {
const compat = expectedParamType && p.type const mismatch =
? isCompatible(p.type, expectedParamType) Boolean(expectedParamType) &&
: 'ok'; Boolean(p.type) &&
isCompatible(p.type!, expectedParamType!) === 'mismatch';
return ( return (
<button <button
key={`${loopId}-${p.path.join('.')}-${i}`} key={`${loopId}-${p.path.join('.')}-${i}`}
type="button" type="button"
className={styles.dataPickerLeaf} className={styles.dataPickerLeaf}
style={{ opacity: compat === 'mismatch' ? 0.45 : 1 }}
onClick={() => handlePick(loopId, p.path, p.type)} onClick={() => handlePick(loopId, p.path, p.type)}
> >
{p.label} {p.label}
@ -343,6 +359,14 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
({p.type}) ({p.type})
</span> </span>
)} )}
{mismatch && (
<span
className={styles.dataPickerMismatchBadge}
title={t('Typ weicht ab — wird beim Ausführen konvertiert')}
>
</span>
)}
</button> </button>
); );
})} })}
@ -392,7 +416,11 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
} }
return filteredIds.map((nodeId) => { return filteredIds.map((nodeId) => {
const node = nodes.find((n) => n.id === nodeId); const node = nodes.find((n) => n.id === nodeId);
const label = node ? getNodeLabel(node) : nodeId; // User-defined step title (or node-type label as fallback)
const stepTitle = node ? getNodeLabel(node) : nodeId;
const nodeTypeDef = node?.type ? nodeTypes.find((nt) => nt.id === node.type) : undefined;
// Human-readable type label (e.g. "Formular", "Web-Recherche")
const typeLabel = nodeTypeDef?.label ?? node?.type ?? '';
const isExpanded = expandedNodes.has(nodeId); const isExpanded = expandedNodes.has(nodeId);
const resolvedSchema = _resolveSchemaForNode( const resolvedSchema = _resolveSchemaForNode(
@ -411,13 +439,21 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
: _buildPathsFromPreview(nodeOutputsPreview[nodeId], [], t('(ganze Ausgabe)')), : _buildPathsFromPreview(nodeOutputsPreview[nodeId], [], t('(ganze Ausgabe)')),
expectedParamType, expectedParamType,
); );
const paths = strictFilter && expectedParamType // Always show all paths; mark mismatches as a visual warning instead of hiding them.
? annotated.filter((p) => { // Recommended entries bubble to the top.
if (p.iterable) return true; const markedPaths = annotated.map((p) => ({
if (!p.type) return false; ...p,
return isCompatible(p.type, expectedParamType) !== 'mismatch'; typeMismatch:
}) strictFilter &&
: annotated; Boolean(expectedParamType) &&
Boolean(p.type) &&
!p.iterable &&
isCompatible(p.type!, expectedParamType!) === 'mismatch',
}));
const paths = [
...markedPaths.filter((p) => p.recommended),
...markedPaths.filter((p) => !p.recommended),
];
return ( return (
<div key={nodeId} className={styles.dataPickerNodeSection}> <div key={nodeId} className={styles.dataPickerNodeSection}>
@ -427,10 +463,10 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
onClick={() => toggleExpand(nodeId)} onClick={() => toggleExpand(nodeId)}
> >
<span className={styles.dataPickerExpandIcon}>{isExpanded ? '▼' : '▶'}</span> <span className={styles.dataPickerExpandIcon}>{isExpanded ? '▼' : '▶'}</span>
<span className={styles.dataPickerNodeLabel}>{label}</span> <span className={styles.dataPickerNodeLabel}>{stepTitle}</span>
{resolvedSchema && ( {typeLabel && (
<span className={styles.dataPickerNodeSchemaHint}> <span className={styles.dataPickerNodeSchemaHint}>
({resolvedSchema.name}) {typeLabel}
</span> </span>
)} )}
</button> </button>
@ -438,12 +474,10 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
<div className={styles.dataPickerTree}> <div className={styles.dataPickerTree}>
{paths.length === 0 && ( {paths.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 kompatiblen Felder — Filter „Nur kompatible“ deaktivieren)')} {t('(keine Felder verfügbar)')}
</div> </div>
)} )}
{paths.map((p, i) => { {paths.map((p, i) => {
const compat =
expectedParamType && p.type ? isCompatible(p.type, expectedParamType) : 'ok';
return ( return (
<div <div
key={`${p.path.join('.')}-${i}`} key={`${p.path.join('.')}-${i}`}
@ -451,16 +485,29 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
> >
<button <button
type="button" type="button"
className={styles.dataPickerLeaf} className={`${styles.dataPickerLeaf}${p.recommended ? ` ${styles.dataPickerLeafRecommended}` : ''}`}
style={{ opacity: compat === 'mismatch' && !p.iterable ? 0.45 : 1, flex: 1 }} style={{ flex: 1 }}
onClick={() => handlePick(nodeId, p.path, p.type)} onClick={() => handlePick(nodeId, p.path, p.type)}
> >
{p.label} {p.label}
{p.recommended && (
<span className={styles.dataPickerRecommendedPill}>
{t('Empfohlen')}
</span>
)}
{p.type && ( {p.type && (
<span className={styles.dataPickerLeafType}> <span className={styles.dataPickerLeafType}>
({p.type}) ({p.type})
</span> </span>
)} )}
{p.typeMismatch && (
<span
className={styles.dataPickerMismatchBadge}
title={t('Typ weicht ab — wird beim Ausführen konvertiert')}
>
</span>
)}
</button> </button>
{p.iterable && ( {p.iterable && (
<button <button

View file

@ -73,13 +73,22 @@ export function createRef(nodeId: string, path: (string | number)[] = [], expect
* Structural type compatibility using the canonical type vocabulary: str / int / float / bool / Any. * Structural type compatibility using the canonical type vocabulary: str / int / float / bool / Any.
* All node parameters and form field schemas must use these types (no `string`, `number`, `boolean` * All node parameters and form field schemas must use these types (no `string`, `number`, `boolean`
* aliases) so no alias-mapping is needed here. * aliases) so no alias-mapping is needed here.
*
* `Any` as expected type accepts everything.
* `Any`, `object`, or `dict` as produced type coerces to `str` (backend serializes via json.dumps).
*/ */
export function isCompatible(producedType: string, expectedType: string): 'ok' | 'coerce' | 'mismatch' { export function isCompatible(producedType: string, expectedType: string): 'ok' | 'coerce' | 'mismatch' {
if (!expectedType || !producedType) return 'ok'; if (!expectedType || !producedType) return 'ok';
if (producedType === expectedType) return 'ok'; if (producedType === expectedType) return 'ok';
if (expectedType === 'Any' || producedType === 'Any') return 'ok'; // Any-expected: accept all sources
if (expectedType === 'Any') return 'ok';
// Any-produced: compatible with everything (coerce where needed)
if (producedType === 'Any') return 'coerce';
// Numeric coercion
if (expectedType === 'str' && (producedType === 'int' || producedType === 'float')) return 'coerce'; if (expectedType === 'str' && (producedType === 'int' || producedType === 'float')) return 'coerce';
if (expectedType === 'int' && producedType === 'str') return 'coerce'; if (expectedType === 'int' && producedType === 'str') return 'coerce';
// Object/dict → str: backend serializes to JSON text
if (expectedType === 'str' && (producedType === 'object' || producedType === 'dict')) return 'coerce';
return 'mismatch'; return 'mismatch';
} }

View file

@ -24,11 +24,110 @@ export function getCategoryIcon(categoryId: string): React.ReactNode {
/** Function type for resolving localized labels */ /** Function type for resolving localized labels */
export type GetLabelFn = (text: string | Record<string, string> | undefined, lang?: string) => string; export type GetLabelFn = (text: string | Record<string, string> | undefined, lang?: string) => string;
/** Build an HTML accept attribute from an upload node config's allowedTypes array. */ /** Extension → MIME when the browser leaves ``File.type`` empty (common on Windows). */
export function getAcceptStringFromConfig( const _EXT_TO_MIME: Record<string, string> = {
config: Record<string, unknown> '.pdf': 'application/pdf',
): string { '.doc': 'application/msword',
const types = config.allowedTypes; '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
if (!Array.isArray(types) || types.length === 0) return '*'; '.xls': 'application/vnd.ms-excel',
return types.join(','); '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.ppt': 'application/vnd.ms-powerpoint',
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'.txt': 'text/plain',
'.csv': 'text/csv',
'.json': 'application/json',
'.xml': 'application/xml',
'.zip': 'application/zip',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.jpe': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
};
function _extensionVariants(ext: string): string[] {
const e = ext.toLowerCase();
if (e === '.jpeg' || e === '.jpe') return ['.jpeg', '.jpe', '.jpg'];
if (e === '.jpg') return ['.jpg', '.jpeg', '.jpe'];
return [e];
}
/**
* True if ``file`` satisfies an HTML-style ``accept`` string (extensions, MIME types, ``image/*``).
* - ``*`` or empty allow all
* - Normalizes gateway multiselect tokens ``pdf`` ``.pdf`` (via {@link getAcceptStringFromConfig})
* - Infers MIME from extension when ``file.type`` is empty
*/
export function fileMatchesAccept(file: File, accept: string): boolean {
const trimmed = (accept ?? '').trim();
if (!trimmed || trimmed === '*') return true;
const parts = trimmed.split(',').map((s) => s.trim()).filter(Boolean);
if (parts.length === 0) return true;
const name = file.name ?? '';
const ext =
name.includes('.') && !name.endsWith('.')
? '.' + (name.split('.').pop() ?? '').toLowerCase()
: '';
let mime = (file.type ?? '').trim().toLowerCase();
if (!mime && ext && _EXT_TO_MIME[ext]) {
mime = _EXT_TO_MIME[ext];
}
const extVariants = ext ? _extensionVariants(ext) : [];
for (const rawPart of parts) {
for (const p of rawPart.split(',').map((s) => s.trim()).filter(Boolean)) {
const pp = p.toLowerCase();
if (pp === '*') return true;
if (pp.startsWith('.')) {
if (extVariants.some((e) => e === pp)) return true;
continue;
}
if (pp.endsWith('/*')) {
const prefix = pp.slice(0, -2);
if (mime.startsWith(prefix + '/')) return true;
continue;
}
if (pp.includes('/')) {
if (mime === pp) return true;
continue;
}
// Bare token left from legacy configs, e.g. "pdf" without dot
if (/^[a-z0-9]{2,16}$/.test(pp)) {
const dotted = '.' + pp;
if (extVariants.includes(dotted)) return true;
if (extVariants.some((e) => _extensionVariants(e).includes(dotted))) return true;
}
}
}
return false;
}
/**
* Build a combined accept list from ``allowedTypes`` (multiselect: pdf, docx, ) and optional
* manual ``accept`` string on the node.
*/
export function getAcceptStringFromConfig(config: Record<string, unknown>): string {
const fromParam =
typeof config.accept === 'string' && config.accept.trim() ? config.accept.trim() : '';
const types = config.allowedTypes;
let fromAllowed = '';
if (Array.isArray(types) && types.length > 0) {
fromAllowed = types
.map((t) => {
const s = String(t).trim().toLowerCase();
if (!s) return '';
if (s === '*') return '*';
if (s.includes('/') || s.endsWith('/*')) return s;
if (s.startsWith('.')) return s;
return `.${s.replace(/^\.+/, '')}`;
})
.filter(Boolean)
.join(',');
}
if (fromParam && fromAllowed) return `${fromParam},${fromAllowed}`;
if (fromParam) return fromParam;
if (fromAllowed) return fromAllowed;
return '*';
} }

View file

@ -24,7 +24,7 @@ import {
} from '../../../api/workflowApi'; } from '../../../api/workflowApi';
import { useToast } from '../../../contexts/ToastContext'; import { useToast } from '../../../contexts/ToastContext';
import { Popup } from '../../../components/UiComponents/Popup'; import { Popup } from '../../../components/UiComponents/Popup';
import { getAcceptStringFromConfig } from '../../../components/FlowEditor'; import { getAcceptStringFromConfig, fileMatchesAccept } from '../../../components/FlowEditor';
import { useFileOperations } from '../../../hooks/useFiles'; import { useFileOperations } from '../../../hooks/useFiles';
import styles from './Automation2WorkflowsTasks.module.css'; import styles from './Automation2WorkflowsTasks.module.css';
@ -501,25 +501,6 @@ function InputFormClickupTaskField({
); );
} }
function fileMatchesAccept(file: File, accept: string): boolean {
if (!accept || !accept.trim()) return true;
const parts = accept.split(',').map((s) => s.trim()).filter(Boolean);
const ext = '.' + (file.name.split('.').pop() ?? '').toLowerCase();
const mime = (file.type ?? '').toLowerCase();
for (const p of parts) {
const pp = p.toLowerCase();
if (pp.startsWith('.')) {
if (ext === pp) return true;
const exts = pp.split(',').map((x) => (x.trim().startsWith('.') ? x.trim() : '.' + x.trim()));
if (exts.some((e) => e === ext)) return true;
} else if (pp.endsWith('/*')) {
const prefix = pp.slice(0, -2);
if (mime.startsWith(prefix)) return true;
} else if (mime === pp || mime.startsWith(pp + '/')) return true;
}
return false;
}
const TaskCard: React.FC<TaskCardProps> = ({ const TaskCard: React.FC<TaskCardProps> = ({
task, task,
instanceId, instanceId,
@ -787,8 +768,10 @@ const TaskCard: React.FC<TaskCardProps> = ({
e.target.value = ''; e.target.value = '';
return; return;
} }
if (acceptStr && !fileMatchesAccept(file, acceptStr)) { if (acceptStr && acceptStr !== '*' && !fileMatchesAccept(file, acceptStr)) {
setUploadError(`Dateityp von "${file.name}" nicht erlaubt.`); setUploadError(
`Die Datei „${file.name}“ hat ein nicht erlaubtes Format. Bitte eine Datei mit passender Endung verwenden (laut Upload-Schritt im Workflow).`,
);
setUploading(false); setUploading(false);
e.target.value = ''; e.target.value = '';
return; return;
@ -848,7 +831,7 @@ const TaskCard: React.FC<TaskCardProps> = ({
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
accept={acceptStr || undefined} accept={acceptStr === '*' ? undefined : acceptStr || undefined}
multiple={allowMultiple} multiple={allowMultiple}
onChange={handleFileSelect} onChange={handleFileSelect}
style={{ display: 'none' }} style={{ display: 'none' }}