continued testing and improvement

This commit is contained in:
Ida 2026-05-14 19:25:44 +02:00
parent b5084c028e
commit 60ff00802c
7 changed files with 340 additions and 297 deletions

View file

@ -45,7 +45,7 @@ import { NodeSidebar } from './NodeSidebar';
import { CanvasHeader } from './CanvasHeader';
import { TemplatePicker } from './TemplatePicker';
import { getCategoryIcon } from '../nodes/shared/utils';
import { fromApiGraph, toApiGraph } from '../nodes/shared/graphUtils';
import { fromApiGraph, toApiGraph, switchOutputCountFromCases, trimConnectionsForSwitchOutputs } from '../nodes/shared/graphUtils';
import { buildNodeOutputsPreview, setPortTypeCatalog as setRegistryCatalog } from '../nodes/shared/outputPreviewRegistry';
import { findGraphErrors } from '../nodes/shared/paramValidation';
import { getLabel as getParamLabel } from '../nodes/shared/utils';
@ -497,32 +497,40 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
}, [applyGraphWithSync, t]);
const handleNodeParametersChange = useCallback((nodeId: string, parameters: Record<string, unknown>) => {
setCanvasNodes((prev) =>
prev.map((n) => {
setCanvasNodes((prev) => {
const nextNodes = prev.map((n) => {
if (n.id !== nodeId) return n;
const next = { ...n, parameters };
if (n.type === 'flow.switch' && 'cases' in parameters) {
const cases = (parameters.cases as unknown[]) ?? [];
next.outputs = Math.max(1, cases.length);
const newCount = switchOutputCountFromCases(parameters.cases);
next.outputs = newCount;
setCanvasConnections((conns) =>
trimConnectionsForSwitchOutputs(conns, nodeId, n.inputs, newCount)
);
}
return next;
})
);
});
return nextNodes;
});
}, []);
const handleMergeNodeParameters = useCallback((nodeId: string, patch: Record<string, unknown>) => {
setCanvasNodes((prev) =>
prev.map((n) => {
setCanvasNodes((prev) => {
const nextNodes = prev.map((n) => {
if (n.id !== nodeId) return n;
const merged = { ...(n.parameters ?? {}), ...patch };
const next = { ...n, parameters: merged };
if (n.type === 'flow.switch' && 'cases' in merged) {
const cases = (merged.cases as unknown[]) ?? [];
next.outputs = Math.max(1, cases.length);
const newCount = switchOutputCountFromCases(merged.cases);
next.outputs = newCount;
setCanvasConnections((conns) =>
trimConnectionsForSwitchOutputs(conns, nodeId, n.inputs, newCount)
);
}
return next;
})
);
});
return nextNodes;
});
}, []);
const handleNodeUpdate = useCallback(

View file

@ -18,6 +18,7 @@ import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
import { AiBadge } from '../nodes/shared/AiBadge';
import { switchOutputLabel } from '../nodes/shared/graphUtils';
export interface CanvasNode {
id: string;
@ -1960,7 +1961,13 @@ export const FlowCanvas = forwardRef<FlowCanvasHandle, FlowCanvasProps>(function
(!selectedConnectionId ? wireTargetOk : true)) ||
(!!selectedConnectionId && !isOutput && (!used || isCurrentTargetOfSelection));
const nt = nodeTypeMap[node.type];
const outputLabel = isOutput && nt?.outputLabels ? nt.outputLabels[index - node.inputs] : undefined;
const outputIndex = index - node.inputs;
const outputLabel =
isOutput && node.type === 'flow.switch'
? switchOutputLabel(node, outputIndex, t)
: isOutput && nt?.outputLabels
? nt.outputLabels[outputIndex]
: undefined;
return (
<div
key={index}

View file

@ -0,0 +1,274 @@
/**
* Backend-driven case list for flow.switch (depends on value dataRef).
*/
import React from 'react';
import type { FieldRendererProps } from './index';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { isRef, type DataRef } from '../shared/dataRef';
import { toApiGraph } from '../shared/graphUtils';
import { fetchConditionMeta, type ConditionOperatorDef } from '../../../../api/workflowApi';
import { useLanguage } from '../../../../providers/language/LanguageContext';
export interface SwitchCase {
operator: string;
value?: string | number | boolean;
}
function normalizeCase(c: unknown): SwitchCase {
if (c && typeof c === 'object' && 'operator' in (c as object)) {
const o = c as SwitchCase;
const v = o.value;
const safeValue: string | number | boolean | undefined =
typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' ? v : undefined;
return { operator: o.operator ?? 'eq', value: safeValue };
}
const fallbackValue: string | number | boolean | undefined =
typeof c === 'string' || typeof c === 'number' || typeof c === 'boolean' ? c : undefined;
return { operator: 'eq', value: fallbackValue };
}
function operatorsFromCatalog(
catalog: Record<string, ConditionOperatorDef[]> | undefined,
valueKind: string
): ConditionOperatorDef[] {
if (!catalog) return [];
return catalog[valueKind] ?? catalog.unknown ?? [];
}
function sanitizeCases(cases: SwitchCase[], operators: ConditionOperatorDef[]): SwitchCase[] {
if (!operators.length) return cases;
return cases.map((c) => {
const op = operators.find((o) => o.id === c.operator) ?? operators[0];
return {
operator: op.id,
value: op.needsValue ? c.value ?? '' : undefined,
};
});
}
function CaseValueInput({
caseItem,
opDef,
valueKind,
onChange,
t,
}: {
caseItem: SwitchCase;
opDef: ConditionOperatorDef | undefined;
valueKind: string;
onChange: (v: string | number) => void;
t: (key: string) => string;
}) {
const valueInput = opDef?.valueInput;
const val = caseItem.value;
if (
valueInput?.kind === 'select' ||
valueInput?.kind === 'contentType' ||
valueInput?.kind === 'outputMode' ||
valueInput?.kind === 'language' ||
valueInput?.kind === 'mime'
) {
return (
<select
value={String(val ?? '')}
onChange={(e) => onChange(e.target.value)}
style={{ flex: 2, padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
>
<option value="">{t('— wählen —')}</option>
{(valueInput.options ?? []).map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
);
}
return (
<input
type={
valueInput?.kind === 'number' || valueKind === 'number'
? 'number'
: valueInput?.kind === 'date'
? 'date'
: 'text'
}
value={String(val ?? '')}
onChange={(e) =>
onChange(
valueInput?.kind === 'number' || valueKind === 'number'
? parseFloat(e.target.value) || 0
: e.target.value
)
}
placeholder={valueInput?.kind === 'regex' ? t('Regex-Muster') : t('Wert')}
style={{ flex: 2, padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
/>
);
}
export const CaseListEditor: React.FC<FieldRendererProps> = ({
param,
value,
onChange,
allParams,
}) => {
const { t } = useLanguage();
const dataFlow = useAutomation2DataFlow();
const dependsOn =
param.frontendOptions && typeof param.frontendOptions === 'object'
? String((param.frontendOptions as Record<string, unknown>).dependsOn ?? 'value')
: 'value';
const valueParam = allParams?.[dependsOn];
const ref: DataRef | null = isRef(valueParam) ? valueParam : null;
const rawCases = Array.isArray(value) ? value : [];
const cases: SwitchCase[] = rawCases.map(normalizeCase);
const [operators, setOperators] = React.useState<ConditionOperatorDef[]>([]);
const [valueKind, setValueKind] = React.useState('unknown');
const [loading, setLoading] = React.useState(false);
const catalog = dataFlow?.conditionOperatorCatalog;
React.useEffect(() => {
if (!ref) {
const ops = operatorsFromCatalog(catalog, 'unknown');
setOperators(ops);
setValueKind('unknown');
return;
}
let cancelled = false;
const applyMeta = (vk: string, ops: ConditionOperatorDef[]) => {
if (cancelled) return;
setValueKind(vk);
setOperators(ops);
if (cases.length > 0) {
const next = sanitizeCases(cases, ops);
if (JSON.stringify(next) !== JSON.stringify(cases)) {
onChange(next);
}
}
};
if (dataFlow?.instanceId && dataFlow.request) {
setLoading(true);
fetchConditionMeta(dataFlow.request, dataFlow.instanceId, {
graph: toApiGraph(dataFlow.nodes, dataFlow.connections),
nodeId: dataFlow.currentNodeId,
ref: { type: 'ref', nodeId: ref.nodeId, path: ref.path },
})
.then((meta) => applyMeta(meta.valueKind, meta.operators))
.catch(() => applyMeta('unknown', operatorsFromCatalog(catalog, 'unknown')))
.finally(() => {
if (!cancelled) setLoading(false);
});
} else {
applyMeta('unknown', operatorsFromCatalog(catalog, 'unknown'));
}
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ref?.nodeId, JSON.stringify(ref?.path), dataFlow?.currentNodeId, catalog]);
const setCases = (next: SwitchCase[]) => onChange(next);
const addCase = () => {
const opDef = operators[0];
setCases([
...cases,
{
operator: opDef?.id ?? 'eq',
value: opDef?.needsValue ? (valueKind === 'number' ? 0 : '') : undefined,
},
]);
};
if (!ref) {
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 4 }}>
{param.description || param.name}
</label>
<p style={{ fontSize: 12, color: 'var(--text-secondary)', margin: 0 }}>
{t('Zuerst einen Wert im Data Picker wählen')}
</p>
</div>
);
}
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 4 }}>
{param.description || param.name}
</label>
{loading && (
<div style={{ fontSize: 11, color: '#888', marginBottom: 4 }}>
{t('Lade Operatoren…')}
</div>
)}
{cases.map((c, i) => {
const opDef = operators.find((o) => o.id === c.operator) ?? operators[0];
const needsValue = opDef?.needsValue ?? true;
return (
<div key={i} style={{ display: 'flex', gap: 4, marginBottom: 4, alignItems: 'center' }}>
<select
value={c.operator}
onChange={(e) => {
const op = operators.find((o) => o.id === e.target.value);
const next = [...cases];
next[i] = {
operator: e.target.value,
value: op?.needsValue ? cases[i]?.value ?? '' : undefined,
};
setCases(next);
}}
disabled={loading || operators.length === 0}
style={{ flex: 1, padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
>
{operators.map((o) => (
<option key={o.id} value={o.id}>
{o.label}
</option>
))}
</select>
{needsValue && (
<CaseValueInput
caseItem={c}
opDef={opDef}
valueKind={valueKind}
t={t}
onChange={(v) => {
const next = [...cases];
next[i] = { ...next[i], value: v };
setCases(next);
}}
/>
)}
<button
type="button"
onClick={() => setCases(cases.filter((_, j) => j !== i))}
style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer' }}
>
×
</button>
</div>
);
})}
<button
type="button"
onClick={addCase}
disabled={loading || operators.length === 0}
style={{ padding: '4px 10px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', fontSize: 12 }}
>
{t('Fall hinzufügen')}
</button>
</div>
);
};

View file

@ -54,6 +54,7 @@ import { ContextAssignmentsEditor } from './ContextAssignmentsEditor';
import { FeatureInstancePicker } from './FeatureInstancePicker';
import { UserFileFolderPicker } from './UserFileFolderPicker';
import { ConditionEditor } from './ConditionEditor';
import { CaseListEditor } from './CaseListEditor';
import { TemplateTextareaRenderer } from './TemplateTextareaRenderer';
import { getApiBaseUrl } from '../../../../../config/config';
@ -639,37 +640,6 @@ const SharepointPathPicker: React.FC<FieldRendererProps> = ({ param, value, onCh
);
};
const CaseListEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage();
const cases = Array.isArray(value) ? value : [];
const addCase = () => onChange([...cases, { operator: 'eq', value: '' }]);
const removeCase = (idx: number) => onChange(cases.filter((_: unknown, i: number) => i !== idx));
const updateCase = (idx: number, field: string, val: unknown) => {
const next = [...cases];
next[idx] = { ...(next[idx] as Record<string, unknown>), [field]: val };
onChange(next);
};
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
{cases.map((c: Record<string, unknown>, i: number) => (
<div key={i} style={{ display: 'flex', gap: 4, marginBottom: 4 }}>
<select value={String(c.operator || 'eq')} onChange={(e) => updateCase(i, 'operator', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }}>
<option value="eq">{t('ist gleich')}</option>
<option value="neq">{t('ungleich')}</option>
<option value="contains">{t('enthält')}</option>
<option value="gt">{t('größer als')}</option>
<option value="lt">{t('kleiner als')}</option>
</select>
<input type="text" value={String(c.value ?? '')} onChange={(e) => updateCase(i, 'value', e.target.value)} style={{ flex: 2, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
<button onClick={() => removeCase(i)} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer' }}>×</button>
</div>
))}
<button onClick={addCase} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', fontSize: 12 }}>{t('Fall hinzufügen')}</button>
</div>
);
};
const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage();
const ctx = useAutomation2DataFlow();

View file

@ -12,6 +12,39 @@ import type {
} from '../../../../api/workflowApi';
import type { CanvasNode, CanvasConnection } from '../../editor/FlowCanvas';
/** Switch: one output per case plus a default (``Sonst``) port. */
export function switchOutputCountFromCases(cases: unknown): number {
const n = Array.isArray(cases) ? cases.length : 0;
return Math.max(1, n + 1);
}
/** Drop edges from switch output ports that no longer exist after case removal. */
export function trimConnectionsForSwitchOutputs(
connections: CanvasConnection[],
nodeId: string,
nodeInputs: number,
outputCount: number
): CanvasConnection[] {
return connections.filter((c) => {
if (c.sourceId !== nodeId) return true;
const outIdx = c.sourceHandle - nodeInputs;
return outIdx >= 0 && outIdx < outputCount;
});
}
export function switchOutputLabel(
node: CanvasNode,
outputIndex: number,
translate: (key: string) => string
): string | undefined {
if (node.type !== 'flow.switch') return undefined;
const cases = (node.parameters?.cases as unknown[]) ?? [];
const caseCount = Array.isArray(cases) ? cases.length : 0;
if (outputIndex < caseCount) return `${translate('Fall')} ${outputIndex + 1}`;
if (outputIndex === caseCount) return translate('Sonst');
return undefined;
}
export function fromApiGraph(
graph: Automation2Graph,
nodeTypes: NodeType[]
@ -26,7 +59,7 @@ export function fromApiGraph(
let outputs = io.outputs;
if (n.type === 'flow.switch') {
const cases = (n.parameters?.cases as unknown[]) ?? [];
outputs = Math.max(1, cases.length);
outputs = switchOutputCountFromCases(cases);
}
const nt = nodeTypes.find((t) => t.id === n.type);
return {

View file

@ -1,250 +0,0 @@
/**
* Switch node config - RefSourceSelect für Datenquelle, Fälle mit Operator + Wert.
* Gleicher Kontext wie IfElse: typabhängige Operatoren (z.B. Alter < 19, = 30).
*/
import React from 'react';
import type { NodeConfigRendererProps } from '../shared/types';
import { RefSourceSelect, getFieldType } from '../shared/RefSourceSelect';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { isRef, createValue } from '../shared/dataRef';
import { getMimeTypeOptionsFromUploadParams } from '../runtime/fileTypeMimeMapping';
import { operatorsForType } from '../shared/conditionOperators';
import styles from '../../editor/Automation2FlowEditor.module.css';
import { useLanguage } from '../../../../providers/language/LanguageContext';
export interface SwitchCase {
operator: string;
value?: string | number | boolean;
}
function normalizeCase(c: unknown): SwitchCase {
if (c && typeof c === 'object' && 'operator' in (c as object)) {
const o = c as SwitchCase;
const v = o.value;
const safeValue: string | number | boolean | undefined =
typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' ? v : undefined;
return { operator: o.operator ?? 'eq', value: safeValue };
}
const fallbackValue: string | number | boolean | undefined =
typeof c === 'string' || typeof c === 'number' || typeof c === 'boolean' ? c : undefined;
return { operator: 'eq', value: fallbackValue };
}
export const SwitchNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
const { t } = useLanguage();
const dataFlow = useAutomation2DataFlow();
const valueParam = params.value;
const ref = isRef(valueParam) ? valueParam : null;
let staticValue: string | number = '';
if (!ref && valueParam != null) {
if (typeof valueParam === 'object' && 'value' in valueParam) {
const v = (valueParam as { value: unknown }).value;
staticValue = v !== undefined && v !== null ? String(v) : '';
} else if (typeof valueParam === 'string' || typeof valueParam === 'number') {
staticValue = valueParam;
}
}
const rawCases = (params.cases as unknown[]) ?? [];
const cases: SwitchCase[] = rawCases.map(normalizeCase);
const fieldType = dataFlow ? getFieldType(ref, dataFlow.nodes, dataFlow.nodeOutputsPreview) : 'unknown';
const operators = operatorsForType(fieldType);
const isMimeTypeRef =
ref && ref.path?.length >= 2 && ref.path[ref.path.length - 1] === 'mimeType';
const sourceNode = ref && dataFlow
? dataFlow.nodes.find((n: { id: string; type?: string; parameters?: Record<string, unknown> }) => n.id === ref.nodeId)
: null;
const mimeTypeOptions =
isMimeTypeRef && sourceNode?.type === 'input.upload' && sourceNode.parameters
? getMimeTypeOptionsFromUploadParams(sourceNode.parameters as Record<string, unknown>)
: [];
const setValue = (val: unknown) => {
updateParam('value', val);
};
const setCases = (next: SwitchCase[]) => {
updateParam('cases', next);
};
const handleRefChange = (newRef: { type: 'ref'; nodeId: string; path: (string | number)[] } | null) => {
if (newRef) {
setValue(newRef);
} else {
setValue(createValue(staticValue));
}
};
const handleStaticValueChange = (v: string) => {
setValue(createValue(fieldType === 'number' ? parseFloat(v) || 0 : v));
};
const handleCaseOperatorChange = (index: number, op: string) => {
const opDef = operators.find((o) => o.value === op);
const next = [...cases];
next[index] = {
operator: op,
value: opDef?.needsValue ? cases[index]?.value : undefined,
};
setCases(next);
};
const handleCaseValueChange = (index: number, v: string | number | boolean) => {
const next = [...cases];
next[index] = {
...next[index],
value: fieldType === 'number' ? (typeof v === 'number' ? v : parseFloat(String(v)) || 0)
: fieldType === 'boolean' ? (v === true || v === 'true')
: String(v),
};
setCases(next);
};
const renderCaseValueInput = (caseItem: SwitchCase, index: number) => {
const val = caseItem.value;
const valStr = String(val ?? '');
if (mimeTypeOptions.length > 0) {
return (
<select
value={valStr}
onChange={(e) => handleCaseValueChange(index, e.target.value)}
className={styles.startsInput}
>
<option value="">{t('MIME-Typ wählen')}</option>
{mimeTypeOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label} ({o.value})
</option>
))}
</select>
);
}
if (fieldType === 'number') {
return (
<input
type="number"
className={styles.startsInput}
value={valStr}
onChange={(e) => handleCaseValueChange(index, parseFloat(e.target.value) || 0)}
placeholder="0"
/>
);
}
if (fieldType === 'date') {
return (
<input
type="date"
className={styles.startsInput}
value={valStr}
onChange={(e) => handleCaseValueChange(index, e.target.value)}
/>
);
}
if (fieldType === 'boolean') {
return (
<select
value={val === true ? 'true' : val === false ? 'false' : ''}
onChange={(e) => {
const v = e.target.value;
handleCaseValueChange(index, v === 'true' ? true : v === 'false' ? false : '');
}}
className={styles.startsInput}
>
<option value="">{t('Wählen')}</option>
<option value="true">{t('Ja (true)')}</option>
<option value="false">{t('Nein (false)')}</option>
</select>
);
}
return (
<input
type="text"
className={styles.startsInput}
value={valStr}
onChange={(e) => handleCaseValueChange(index, e.target.value)}
placeholder={isMimeTypeRef ? t('z.B. application/pdf') : t('Wert')}
/>
);
};
const addCase = () => {
const opDef = operators[0];
const defaultVal = opDef?.needsValue
? (fieldType === 'number' ? 0 : fieldType === 'boolean' ? false : '')
: undefined;
setCases([
...cases,
{ operator: opDef?.value ?? 'eq', value: defaultVal },
]);
};
return (
<div className={styles.ifElseConditionEditor}>
<div className={styles.ifElseConditionRow}>
<label>{t('Datenquelle')}</label>
<RefSourceSelect
value={ref}
onChange={handleRefChange}
placeholder={t('Feld zum Vergleich wählen')}
/>
</div>
{!ref && (
<div className={styles.ifElseConditionRow}>
<label>{t('Fester Wert (ohne Referenz)')}</label>
<input
type="text"
value={String(staticValue ?? '')}
onChange={(e) => handleStaticValueChange(e.target.value)}
placeholder={t('z. B. CH oder 42')}
/>
</div>
)}
<div className={styles.ifElseConditionRow}>
<label>{t('Fälle / Reihenfolge / Ausgabe')}</label>
<div className={styles.formFieldsList}>
{cases.map((c, i) => {
const opDef = operators.find((o) => o.value === c.operator) ?? operators[0];
const needsValue = opDef?.needsValue ?? true;
return (
<div key={i} className={styles.formFieldRow}>
<select
value={c.operator}
onChange={(e) => handleCaseOperatorChange(i, e.target.value)}
className={styles.startsInput}
style={{ minWidth: 140 }}
>
{operators.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
{needsValue && (
<div style={{ flex: 1 }}>
{renderCaseValueInput(c, i)}
</div>
)}
<button
type="button"
className={styles.formFieldRemoveButton}
onClick={() => setCases(cases.filter((_, j) => j !== i))}
>
</button>
</div>
);
})}
<button type="button" className={styles.startsAddBtn} onClick={addCase}>
{t('+ Fall')}
</button>
</div>
</div>
</div>
);
};

View file

@ -1 +1,2 @@
export { SwitchNodeConfig } from './SwitchNodeConfig';
export { CaseListEditor as SwitchNodeConfig } from '../frontendTypeRenderers/CaseListEditor';
export type { SwitchCase } from '../frontendTypeRenderers/CaseListEditor';