ValueOn Lead to Opportunity durchgespielt, bugfixes im datapicker und node hadover
This commit is contained in:
parent
1d2d247273
commit
3d580a5fca
8 changed files with 500 additions and 106 deletions
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 '*';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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' }}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue