continued testing and improvement
This commit is contained in:
parent
c61ccf4330
commit
f617a2d701
7 changed files with 340 additions and 297 deletions
|
|
@ -45,7 +45,7 @@ import { NodeSidebar } from './NodeSidebar';
|
||||||
import { CanvasHeader } from './CanvasHeader';
|
import { CanvasHeader } from './CanvasHeader';
|
||||||
import { TemplatePicker } from './TemplatePicker';
|
import { TemplatePicker } from './TemplatePicker';
|
||||||
import { getCategoryIcon } from '../nodes/shared/utils';
|
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 { buildNodeOutputsPreview, setPortTypeCatalog as setRegistryCatalog } from '../nodes/shared/outputPreviewRegistry';
|
||||||
import { findGraphErrors } from '../nodes/shared/paramValidation';
|
import { findGraphErrors } from '../nodes/shared/paramValidation';
|
||||||
import { getLabel as getParamLabel } from '../nodes/shared/utils';
|
import { getLabel as getParamLabel } from '../nodes/shared/utils';
|
||||||
|
|
@ -497,32 +497,40 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
}, [applyGraphWithSync, t]);
|
}, [applyGraphWithSync, t]);
|
||||||
|
|
||||||
const handleNodeParametersChange = useCallback((nodeId: string, parameters: Record<string, unknown>) => {
|
const handleNodeParametersChange = useCallback((nodeId: string, parameters: Record<string, unknown>) => {
|
||||||
setCanvasNodes((prev) =>
|
setCanvasNodes((prev) => {
|
||||||
prev.map((n) => {
|
const nextNodes = prev.map((n) => {
|
||||||
if (n.id !== nodeId) return n;
|
if (n.id !== nodeId) return n;
|
||||||
const next = { ...n, parameters };
|
const next = { ...n, parameters };
|
||||||
if (n.type === 'flow.switch' && 'cases' in parameters) {
|
if (n.type === 'flow.switch' && 'cases' in parameters) {
|
||||||
const cases = (parameters.cases as unknown[]) ?? [];
|
const newCount = switchOutputCountFromCases(parameters.cases);
|
||||||
next.outputs = Math.max(1, cases.length);
|
next.outputs = newCount;
|
||||||
|
setCanvasConnections((conns) =>
|
||||||
|
trimConnectionsForSwitchOutputs(conns, nodeId, n.inputs, newCount)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
})
|
});
|
||||||
);
|
return nextNodes;
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleMergeNodeParameters = useCallback((nodeId: string, patch: Record<string, unknown>) => {
|
const handleMergeNodeParameters = useCallback((nodeId: string, patch: Record<string, unknown>) => {
|
||||||
setCanvasNodes((prev) =>
|
setCanvasNodes((prev) => {
|
||||||
prev.map((n) => {
|
const nextNodes = prev.map((n) => {
|
||||||
if (n.id !== nodeId) return n;
|
if (n.id !== nodeId) return n;
|
||||||
const merged = { ...(n.parameters ?? {}), ...patch };
|
const merged = { ...(n.parameters ?? {}), ...patch };
|
||||||
const next = { ...n, parameters: merged };
|
const next = { ...n, parameters: merged };
|
||||||
if (n.type === 'flow.switch' && 'cases' in merged) {
|
if (n.type === 'flow.switch' && 'cases' in merged) {
|
||||||
const cases = (merged.cases as unknown[]) ?? [];
|
const newCount = switchOutputCountFromCases(merged.cases);
|
||||||
next.outputs = Math.max(1, cases.length);
|
next.outputs = newCount;
|
||||||
|
setCanvasConnections((conns) =>
|
||||||
|
trimConnectionsForSwitchOutputs(conns, nodeId, n.inputs, newCount)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
})
|
});
|
||||||
);
|
return nextNodes;
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleNodeUpdate = useCallback(
|
const handleNodeUpdate = useCallback(
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import styles from './Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
import { AiBadge } from '../nodes/shared/AiBadge';
|
import { AiBadge } from '../nodes/shared/AiBadge';
|
||||||
|
import { switchOutputLabel } from '../nodes/shared/graphUtils';
|
||||||
|
|
||||||
export interface CanvasNode {
|
export interface CanvasNode {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -1960,7 +1961,13 @@ export const FlowCanvas = forwardRef<FlowCanvasHandle, FlowCanvasProps>(function
|
||||||
(!selectedConnectionId ? wireTargetOk : true)) ||
|
(!selectedConnectionId ? wireTargetOk : true)) ||
|
||||||
(!!selectedConnectionId && !isOutput && (!used || isCurrentTargetOfSelection));
|
(!!selectedConnectionId && !isOutput && (!used || isCurrentTargetOfSelection));
|
||||||
const nt = nodeTypeMap[node.type];
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -54,6 +54,7 @@ import { ContextAssignmentsEditor } from './ContextAssignmentsEditor';
|
||||||
import { FeatureInstancePicker } from './FeatureInstancePicker';
|
import { FeatureInstancePicker } from './FeatureInstancePicker';
|
||||||
import { UserFileFolderPicker } from './UserFileFolderPicker';
|
import { UserFileFolderPicker } from './UserFileFolderPicker';
|
||||||
import { ConditionEditor } from './ConditionEditor';
|
import { ConditionEditor } from './ConditionEditor';
|
||||||
|
import { CaseListEditor } from './CaseListEditor';
|
||||||
import { TemplateTextareaRenderer } from './TemplateTextareaRenderer';
|
import { TemplateTextareaRenderer } from './TemplateTextareaRenderer';
|
||||||
import { getApiBaseUrl } from '../../../../../config/config';
|
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 FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const ctx = useAutomation2DataFlow();
|
const ctx = useAutomation2DataFlow();
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,39 @@ import type {
|
||||||
} from '../../../../api/workflowApi';
|
} from '../../../../api/workflowApi';
|
||||||
import type { CanvasNode, CanvasConnection } from '../../editor/FlowCanvas';
|
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(
|
export function fromApiGraph(
|
||||||
graph: Automation2Graph,
|
graph: Automation2Graph,
|
||||||
nodeTypes: NodeType[]
|
nodeTypes: NodeType[]
|
||||||
|
|
@ -26,7 +59,7 @@ export function fromApiGraph(
|
||||||
let outputs = io.outputs;
|
let outputs = io.outputs;
|
||||||
if (n.type === 'flow.switch') {
|
if (n.type === 'flow.switch') {
|
||||||
const cases = (n.parameters?.cases as unknown[]) ?? [];
|
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);
|
const nt = nodeTypes.find((t) => t.id === n.type);
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
export { SwitchNodeConfig } from './SwitchNodeConfig';
|
export { CaseListEditor as SwitchNodeConfig } from '../frontendTypeRenderers/CaseListEditor';
|
||||||
|
export type { SwitchCase } from '../frontendTypeRenderers/CaseListEditor';
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue