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>;
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 '*';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' }}
|
||||
|
|
|
|||
Loading…
Reference in a new issue