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>;
required: boolean;
enumValues?: string[] | null;
/** When true, surface at the top of the DataPicker as the most common/recommended pick. */
recommended?: boolean;
}
export interface PortSchema {

View file

@ -1725,6 +1725,35 @@
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
* the picker's white background and on the leaf's blue hover background. */
.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 type { CanvasNode } from '../../editor/FlowCanvas';
import { DataRefRenderer } from './DataRefRenderer';
import { ContextBuilderRenderer } from './ContextBuilderRenderer';
import { FeatureInstancePicker } from './FeatureInstancePicker';
import { TemplateTextareaRenderer } from './TemplateTextareaRenderer';
import { getApiBaseUrl } from '../../../../../config/config';
@ -547,28 +548,65 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
next[idx] = { ...(next[idx] as Record<string, unknown>), [field]: val };
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 (
<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) => (
<div key={i} style={{ display: 'flex', gap: 4, marginBottom: 4, alignItems: 'center' }}>
<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' }} />
<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 key={i} style={{ background: '#f9f9f9', border: '1px solid #e0e0e0', borderRadius: 6, padding: '8px 10px', marginBottom: 6 }}>
{/* Row 1: Bezeichnung + delete */}
<div style={{ display: 'flex', gap: 6, marginBottom: 6, alignItems: 'center' }}>
<input
type="text"
placeholder={t('Bezeichnung (Anzeigename)')}
value={String(f.label ?? '')}
onChange={(e) => updateField(i, 'label', e.target.value)}
style={{ ...inputStyle, flex: 1, fontWeight: 500 }}
/>
<button
type="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>
<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' }} />
<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')}
</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>
<button onClick={() => removeField(i)} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer' }}>×</button>
</div>
{String(f.type) === 'group' && (
<div style={{ width: '100%', marginTop: 6, marginLeft: 8, borderLeft: '2px solid #ddd', paddingLeft: 8 }}>
<div style={{ fontSize: 11, color: '#666', marginBottom: 4 }}>{t('Unterfelder')}</div>
<div style={{ marginTop: 8, borderTop: '1px solid #e0e0e0', paddingTop: 8 }}>
<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) => (
<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 }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<input
type="text"
placeholder={t('Name')}
@ -578,7 +616,7 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
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')}
@ -587,7 +625,7 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
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>
@ -599,10 +637,9 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
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' }}
>
×
</button>
style={{ padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', background: '#fff', color: '#999', flexShrink: 0 }}
>×</button>
</div>
</div>
))}
<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 }];
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>
</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>
);
};
@ -869,6 +912,7 @@ export const FRONTEND_TYPE_RENDERERS: Record<string, FieldRendererComponent> = {
file: TextInput,
hidden: HiddenInput,
dataRef: DataRefRenderer,
contextBuilder: ContextBuilderRenderer,
userConnection: ConnectionPicker,
featureInstance: FeatureInstancePicker,
sharepointFolder: SharepointPathPicker,

View file

@ -35,6 +35,10 @@ interface PickablePath {
/** True iff this path produces `List[X]` and the consumer expects `X`
* picking with iterate=true appends the wildcard segment. */
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\[(.+)\]$/;
@ -47,10 +51,22 @@ function _buildPathsFromSchema(
): PickablePath[] {
if (!schema || !schema.fields || depth > 8) return [];
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) {
const fieldPath = [...basePath, field.name];
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 inner = m?.[1]?.trim();
if (inner && catalog[inner]) {
@ -326,15 +342,15 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
<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';
const mismatch =
Boolean(expectedParamType) &&
Boolean(p.type) &&
isCompatible(p.type!, expectedParamType!) === 'mismatch';
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}
@ -343,6 +359,14 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
({p.type})
</span>
)}
{mismatch && (
<span
className={styles.dataPickerMismatchBadge}
title={t('Typ weicht ab — wird beim Ausführen konvertiert')}
>
</span>
)}
</button>
);
})}
@ -392,7 +416,11 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
}
return filteredIds.map((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 resolvedSchema = _resolveSchemaForNode(
@ -411,13 +439,21 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
: _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;
// 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:
strictFilter &&
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 (
<div key={nodeId} className={styles.dataPickerNodeSection}>
@ -427,10 +463,10 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
onClick={() => toggleExpand(nodeId)}
>
<span className={styles.dataPickerExpandIcon}>{isExpanded ? '▼' : '▶'}</span>
<span className={styles.dataPickerNodeLabel}>{label}</span>
{resolvedSchema && (
<span className={styles.dataPickerNodeLabel}>{stepTitle}</span>
{typeLabel && (
<span className={styles.dataPickerNodeSchemaHint}>
({resolvedSchema.name})
{typeLabel}
</span>
)}
</button>
@ -438,12 +474,10 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
<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)')}
{t('(keine Felder verfügbar)')}
</div>
)}
{paths.map((p, i) => {
const compat =
expectedParamType && p.type ? isCompatible(p.type, expectedParamType) : 'ok';
return (
<div
key={`${p.path.join('.')}-${i}`}
@ -451,16 +485,29 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
>
<button
type="button"
className={styles.dataPickerLeaf}
style={{ opacity: compat === 'mismatch' && !p.iterable ? 0.45 : 1, flex: 1 }}
className={`${styles.dataPickerLeaf}${p.recommended ? ` ${styles.dataPickerLeafRecommended}` : ''}`}
style={{ flex: 1 }}
onClick={() => handlePick(nodeId, p.path, p.type)}
>
{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

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.
* All node parameters and form field schemas must use these types (no `string`, `number`, `boolean`
* 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' {
if (!expectedType || !producedType) 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 === 'int' && producedType === 'str') return 'coerce';
// Object/dict → str: backend serializes to JSON text
if (expectedType === 'str' && (producedType === 'object' || producedType === 'dict')) return 'coerce';
return 'mismatch';
}

View file

@ -24,11 +24,110 @@ export function getCategoryIcon(categoryId: string): React.ReactNode {
/** Function type for resolving localized labels */
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. */
export function getAcceptStringFromConfig(
config: Record<string, unknown>
): string {
const types = config.allowedTypes;
if (!Array.isArray(types) || types.length === 0) return '*';
return types.join(',');
/** Extension → MIME when the browser leaves ``File.type`` empty (common on Windows). */
const _EXT_TO_MIME: Record<string, string> = {
'.pdf': 'application/pdf',
'.doc': 'application/msword',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.xls': 'application/vnd.ms-excel',
'.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';
import { useToast } from '../../../contexts/ToastContext';
import { Popup } from '../../../components/UiComponents/Popup';
import { getAcceptStringFromConfig } from '../../../components/FlowEditor';
import { getAcceptStringFromConfig, fileMatchesAccept } from '../../../components/FlowEditor';
import { useFileOperations } from '../../../hooks/useFiles';
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> = ({
task,
instanceId,
@ -787,8 +768,10 @@ const TaskCard: React.FC<TaskCardProps> = ({
e.target.value = '';
return;
}
if (acceptStr && !fileMatchesAccept(file, acceptStr)) {
setUploadError(`Dateityp von "${file.name}" nicht erlaubt.`);
if (acceptStr && acceptStr !== '*' && !fileMatchesAccept(file, acceptStr)) {
setUploadError(
`Die Datei „${file.name}“ hat ein nicht erlaubtes Format. Bitte eine Datei mit passender Endung verwenden (laut Upload-Schritt im Workflow).`,
);
setUploading(false);
e.target.value = '';
return;
@ -848,7 +831,7 @@ const TaskCard: React.FC<TaskCardProps> = ({
<input
ref={fileInputRef}
type="file"
accept={acceptStr || undefined}
accept={acceptStr === '*' ? undefined : acceptStr || undefined}
multiple={allowMultiple}
onChange={handleFileSelect}
style={{ display: 'none' }}