From f617a2d7017989b5ca0f17382fdcc4a6e057c7e0 Mon Sep 17 00:00:00 2001 From: Ida Date: Thu, 14 May 2026 19:25:44 +0200 Subject: [PATCH] continued testing and improvement --- .../editor/Automation2FlowEditor.tsx | 34 ++- .../FlowEditor/editor/FlowCanvas.tsx | 9 +- .../frontendTypeRenderers/CaseListEditor.tsx | 274 ++++++++++++++++++ .../nodes/frontendTypeRenderers/index.tsx | 32 +- .../FlowEditor/nodes/shared/graphUtils.ts | 35 ++- .../nodes/switch/SwitchNodeConfig.tsx | 250 ---------------- .../FlowEditor/nodes/switch/index.ts | 3 +- 7 files changed, 340 insertions(+), 297 deletions(-) create mode 100644 src/components/FlowEditor/nodes/frontendTypeRenderers/CaseListEditor.tsx delete mode 100644 src/components/FlowEditor/nodes/switch/SwitchNodeConfig.tsx diff --git a/src/components/FlowEditor/editor/Automation2FlowEditor.tsx b/src/components/FlowEditor/editor/Automation2FlowEditor.tsx index c4ffdfe..1e8c157 100644 --- a/src/components/FlowEditor/editor/Automation2FlowEditor.tsx +++ b/src/components/FlowEditor/editor/Automation2FlowEditor.tsx @@ -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 = ({ in }, [applyGraphWithSync, t]); const handleNodeParametersChange = useCallback((nodeId: string, parameters: Record) => { - 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) => { - 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( diff --git a/src/components/FlowEditor/editor/FlowCanvas.tsx b/src/components/FlowEditor/editor/FlowCanvas.tsx index 5f04fe3..5a8010b 100644 --- a/src/components/FlowEditor/editor/FlowCanvas.tsx +++ b/src/components/FlowEditor/editor/FlowCanvas.tsx @@ -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(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 (
| 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 ( + + ); + } + + return ( + + 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 = ({ + param, + value, + onChange, + allParams, +}) => { + const { t } = useLanguage(); + const dataFlow = useAutomation2DataFlow(); + const dependsOn = + param.frontendOptions && typeof param.frontendOptions === 'object' + ? String((param.frontendOptions as Record).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([]); + 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 ( +
+ +

+ {t('Zuerst einen Wert im Data Picker wählen')} +

+
+ ); + } + + return ( +
+ + {loading && ( +
+ {t('Lade Operatoren…')} +
+ )} + {cases.map((c, i) => { + const opDef = operators.find((o) => o.id === c.operator) ?? operators[0]; + const needsValue = opDef?.needsValue ?? true; + return ( +
+ + {needsValue && ( + { + const next = [...cases]; + next[i] = { ...next[i], value: v }; + setCases(next); + }} + /> + )} + +
+ ); + })} + +
+ ); +}; diff --git a/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx b/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx index dc8edb2..d383c22 100644 --- a/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx +++ b/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx @@ -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 = ({ param, value, onCh ); }; -const CaseListEditor: React.FC = ({ 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), [field]: val }; - onChange(next); - }; - return ( -
- - {cases.map((c: Record, i: number) => ( -
- - updateCase(i, 'value', e.target.value)} style={{ flex: 2, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} /> - -
- ))} - -
- ); -}; - const FieldBuilderEditor: React.FC = ({ param, value, onChange }) => { const { t } = useLanguage(); const ctx = useAutomation2DataFlow(); diff --git a/src/components/FlowEditor/nodes/shared/graphUtils.ts b/src/components/FlowEditor/nodes/shared/graphUtils.ts index 2f63a22..06199fa 100644 --- a/src/components/FlowEditor/nodes/shared/graphUtils.ts +++ b/src/components/FlowEditor/nodes/shared/graphUtils.ts @@ -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 { diff --git a/src/components/FlowEditor/nodes/switch/SwitchNodeConfig.tsx b/src/components/FlowEditor/nodes/switch/SwitchNodeConfig.tsx deleted file mode 100644 index 647abda..0000000 --- a/src/components/FlowEditor/nodes/switch/SwitchNodeConfig.tsx +++ /dev/null @@ -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 = ({ 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 }) => n.id === ref.nodeId) - : null; - const mimeTypeOptions = - isMimeTypeRef && sourceNode?.type === 'input.upload' && sourceNode.parameters - ? getMimeTypeOptionsFromUploadParams(sourceNode.parameters as Record) - : []; - - 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 ( - - ); - } - if (fieldType === 'number') { - return ( - handleCaseValueChange(index, parseFloat(e.target.value) || 0)} - placeholder="0" - /> - ); - } - if (fieldType === 'date') { - return ( - handleCaseValueChange(index, e.target.value)} - /> - ); - } - if (fieldType === 'boolean') { - return ( - - ); - } - return ( - 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 ( -
-
- - -
- - {!ref && ( -
- - handleStaticValueChange(e.target.value)} - placeholder={t('z. B. CH oder 42')} - /> -
- )} - -
- -
- {cases.map((c, i) => { - const opDef = operators.find((o) => o.value === c.operator) ?? operators[0]; - const needsValue = opDef?.needsValue ?? true; - return ( -
- - {needsValue && ( -
- {renderCaseValueInput(c, i)} -
- )} - -
- ); - })} - -
-
-
- ); -}; diff --git a/src/components/FlowEditor/nodes/switch/index.ts b/src/components/FlowEditor/nodes/switch/index.ts index ad8584e..0d88897 100644 --- a/src/components/FlowEditor/nodes/switch/index.ts +++ b/src/components/FlowEditor/nodes/switch/index.ts @@ -1 +1,2 @@ -export { SwitchNodeConfig } from './SwitchNodeConfig'; +export { CaseListEditor as SwitchNodeConfig } from '../frontendTypeRenderers/CaseListEditor'; +export type { SwitchCase } from '../frontendTypeRenderers/CaseListEditor';