From b5084c028e53da8d96604ca6899d356060490ad9 Mon Sep 17 00:00:00 2001 From: Ida Date: Thu, 14 May 2026 18:38:44 +0200 Subject: [PATCH] fix: handover fix, if/else node extended comparison mode --- src/api/workflowApi.ts | 46 +++- .../context/Automation2DataFlowContext.tsx | 9 +- .../editor/Automation2FlowEditor.tsx | 5 + .../frontendTypeRenderers/ConditionEditor.tsx | 223 ++++++++++++++++++ .../nodes/frontendTypeRenderers/index.tsx | 26 +- .../nodes/ifElse/IfElseNodeConfig.tsx | 154 ------------ .../FlowEditor/nodes/ifElse/index.ts | 3 +- src/pages/AutomationsDashboardPage.tsx | 23 +- 8 files changed, 302 insertions(+), 187 deletions(-) create mode 100644 src/components/FlowEditor/nodes/frontendTypeRenderers/ConditionEditor.tsx delete mode 100644 src/components/FlowEditor/nodes/ifElse/IfElseNodeConfig.tsx diff --git a/src/api/workflowApi.ts b/src/api/workflowApi.ts index 7d7b6c8..c4144d4 100644 --- a/src/api/workflowApi.ts +++ b/src/api/workflowApi.ts @@ -116,10 +116,19 @@ export interface FormFieldType { portType: string; } +export interface ConditionOperatorDef { + id: string; + label: string; + labelKey?: string; + needsValue: boolean; + valueInput?: { kind: string; options?: string[] }; +} + export interface NodeTypesResponse { nodeTypes: NodeType[]; categories: NodeTypeCategory[]; portTypeCatalog?: Record; + conditionOperatorCatalog?: Record; systemVariables?: Record; formFieldTypes?: FormFieldType[]; } @@ -310,15 +319,17 @@ export async function fetchNodeTypes( const nodeTypes = data?.nodeTypes ?? []; const categories = data?.categories ?? []; const portTypeCatalog = data?.portTypeCatalog ?? undefined; + const conditionOperatorCatalog = data?.conditionOperatorCatalog ?? undefined; const systemVariables = data?.systemVariables ?? undefined; const formFieldTypes = data?.formFieldTypes ?? undefined; console.log( `${LOG} fetchNodeTypes response: ${nodeTypes.length} nodeTypes, ${categories.length} categories, ` + `${portTypeCatalog ? Object.keys(portTypeCatalog).length : 0} portTypes, ` + + `${conditionOperatorCatalog ? Object.keys(conditionOperatorCatalog).length : 0} conditionKinds, ` + `${systemVariables ? Object.keys(systemVariables).length : 0} sysVars, ` + `${formFieldTypes ? formFieldTypes.length : 0} formFieldTypes` ); - return { nodeTypes, categories, portTypeCatalog, systemVariables, formFieldTypes }; + return { nodeTypes, categories, portTypeCatalog, conditionOperatorCatalog, systemVariables, formFieldTypes }; } export interface UpstreamPathEntry { @@ -328,6 +339,39 @@ export interface UpstreamPathEntry { type: string; label: string; scopeOrigin: 'data' | 'loop' | 'system'; + valueKind?: string; +} + +export interface ConditionMetaResponse { + valueKind: string; + operators: ConditionOperatorDef[]; +} + +export interface ConditionMetaRequest { + graph: Automation2Graph; + nodeId?: string; + ref: { type: 'ref'; nodeId: string; path: (string | number)[] }; +} + +/** + * POST /api/workflows/{instanceId}/condition-meta — operators for a DataRef (If/Else). + */ +export async function fetchConditionMeta( + request: ApiRequestFunction, + instanceId: string, + body: ConditionMetaRequest, + language = 'de' +): Promise { + const data = await request({ + url: `/api/workflows/${instanceId}/condition-meta`, + method: 'post', + params: { language }, + data: body, + }); + return { + valueKind: String(data?.valueKind ?? 'unknown'), + operators: (data?.operators ?? []) as ConditionOperatorDef[], + }; } /** diff --git a/src/components/FlowEditor/context/Automation2DataFlowContext.tsx b/src/components/FlowEditor/context/Automation2DataFlowContext.tsx index f36f87c..1284ff9 100644 --- a/src/components/FlowEditor/context/Automation2DataFlowContext.tsx +++ b/src/components/FlowEditor/context/Automation2DataFlowContext.tsx @@ -6,7 +6,7 @@ import React, { createContext, useContext, useMemo } from 'react'; import type { CanvasNode, CanvasConnection } from '../editor/FlowCanvas'; import { getAvailableSources } from '../nodes/shared/dataFlowGraph'; -import type { ApiRequestFunction, FormFieldType, NodeType, PortField, PortSchema, SystemVariable } from '../../../api/workflowApi'; +import type { ApiRequestFunction, ConditionOperatorDef, FormFieldType, NodeType, PortField, PortSchema, SystemVariable } from '../../../api/workflowApi'; export interface Automation2DataFlowContextValue { currentNodeId: string; @@ -19,6 +19,8 @@ export interface Automation2DataFlowContextValue { systemVariables: Record; /** Canonical form field types from the API — maps UI type id to portType primitive. */ formFieldTypes: FormFieldType[]; + /** Backend-driven condition operators per valueKind (flow.ifElse). */ + conditionOperatorCatalog: Record; getNodeLabel: (node: { id: string; title?: string; label?: string; type?: string }) => string; getAvailableSourceIds: () => string[]; /** Present when rendered inside the flow editor (ConnectionPicker / tools). */ @@ -44,6 +46,7 @@ interface Automation2DataFlowProviderProps { portTypeCatalog?: Record; systemVariables?: Record; formFieldTypes?: FormFieldType[]; + conditionOperatorCatalog?: Record; instanceId?: string; request?: ApiRequestFunction; children: React.ReactNode; @@ -59,6 +62,7 @@ export const Automation2DataFlowProvider: React.FC n.title ?? n.label ?? n.type ?? n.id, getAvailableSourceIds: () => getAvailableSources(node.id, nodes, connections), @@ -127,7 +132,7 @@ export const Automation2DataFlowProvider: React.FC diff --git a/src/components/FlowEditor/editor/Automation2FlowEditor.tsx b/src/components/FlowEditor/editor/Automation2FlowEditor.tsx index e9ef634..c4ffdfe 100644 --- a/src/components/FlowEditor/editor/Automation2FlowEditor.tsx +++ b/src/components/FlowEditor/editor/Automation2FlowEditor.tsx @@ -111,6 +111,9 @@ export const Automation2FlowEditor: React.FC = ({ in const [portTypeCatalog, setPortTypeCatalog] = useState>({}); const [systemVariables, setSystemVariables] = useState>({}); const [formFieldTypes, setFormFieldTypes] = useState([]); + const [conditionOperatorCatalog, setConditionOperatorCatalog] = useState< + Record + >({}); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [filter, setFilter] = useState(''); @@ -545,6 +548,7 @@ export const Automation2FlowEditor: React.FC = ({ in } if (data.systemVariables) setSystemVariables(data.systemVariables); if (data.formFieldTypes) setFormFieldTypes(data.formFieldTypes); + if (data.conditionOperatorCatalog) setConditionOperatorCatalog(data.conditionOperatorCatalog); } catch (err: unknown) { setError(err instanceof Error ? err.message : String(err)); setNodeTypes([]); @@ -1024,6 +1028,7 @@ export const Automation2FlowEditor: React.FC = ({ in portTypeCatalog={portTypeCatalog as Record} systemVariables={systemVariables as Record} formFieldTypes={formFieldTypes} + conditionOperatorCatalog={conditionOperatorCatalog} instanceId={instanceId} request={request} > diff --git a/src/components/FlowEditor/nodes/frontendTypeRenderers/ConditionEditor.tsx b/src/components/FlowEditor/nodes/frontendTypeRenderers/ConditionEditor.tsx new file mode 100644 index 0000000..ff23dfc --- /dev/null +++ b/src/components/FlowEditor/nodes/frontendTypeRenderers/ConditionEditor.tsx @@ -0,0 +1,223 @@ +/** + * Backend-driven condition editor for flow.ifElse (depends on Item 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 StructuredCondition { + type: 'condition'; + operator: string; + value?: string | number; + /** Legacy — ignored when Item is set */ + ref?: DataRef | null; +} + +function parseCondition(v: unknown): StructuredCondition { + if (v && typeof v === 'object' && (v as StructuredCondition).type === 'condition') { + const c = v as StructuredCondition; + return { type: 'condition', operator: c.operator ?? 'eq', value: c.value }; + } + return { type: 'condition', operator: 'eq', value: '' }; +} + +function operatorsFromCatalog( + catalog: Record | undefined, + valueKind: string +): ConditionOperatorDef[] { + if (!catalog) return []; + return catalog[valueKind] ?? catalog.unknown ?? []; +} + +export const ConditionEditor: 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 ?? 'Item') + : 'Item'; + + const itemRef = allParams?.[dependsOn]; + const ref: DataRef | null = isRef(itemRef) ? itemRef : null; + + const cond = parseCondition(value); + 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) { + setOperators([]); + setValueKind('unknown'); + return; + } + + let cancelled = false; + + const applyMeta = (vk: string, ops: ConditionOperatorDef[]) => { + if (cancelled) return; + setValueKind(vk); + setOperators(ops); + const valid = ops.some((o) => o.id === cond.operator); + if (!valid && ops.length > 0) { + const first = ops[0]; + onChange({ + type: 'condition', + operator: first.id, + value: first.needsValue ? cond.value ?? '' : undefined, + }); + } + }; + + 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(() => { + const ops = operatorsFromCatalog(catalog, 'unknown'); + applyMeta('unknown', ops); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + } else { + const ops = operatorsFromCatalog(catalog, 'unknown'); + applyMeta('unknown', ops); + } + + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- reset operators when Item ref changes + }, [ref?.nodeId, JSON.stringify(ref?.path), dataFlow?.currentNodeId, catalog]); + + const currentOp = operators.find((o) => o.id === cond.operator) ?? operators[0]; + const needsValue = currentOp?.needsValue ?? true; + const valueInput = currentOp?.valueInput; + + const setCondition = (next: StructuredCondition) => { + onChange(next); + }; + + if (!ref) { + return ( +
+ +

+ {t('Zuerst ein Item im Data Picker wählen')} +

+
+ ); + } + + const handleOperatorChange = (opId: string) => { + const opDef = operators.find((o) => o.id === opId); + setCondition({ + type: 'condition', + operator: opId, + value: opDef?.needsValue ? cond.value ?? '' : undefined, + }); + }; + + const handleValueChange = (v: string | number) => { + const kind = valueInput?.kind; + const parsed = + kind === 'number' || valueKind === 'number' ? parseFloat(String(v)) || 0 : String(v); + setCondition({ type: 'condition', operator: cond.operator, value: parsed }); + }; + + return ( +
+ + + + + + {loading && ( +
{t('Lade Operatoren…')}
+ )} + {needsValue && ( + + + {valueInput?.kind === 'select' || + valueInput?.kind === 'contentType' || + valueInput?.kind === 'outputMode' || + valueInput?.kind === 'language' || + valueInput?.kind === 'mime' ? ( + + ) : ( + + handleValueChange( + valueInput?.kind === 'number' ? parseFloat(e.target.value) || 0 : e.target.value + ) + } + placeholder={ + valueInput?.kind === 'regex' ? t('Regex-Muster') : t('Wert eingeben') + } + style={{ flex: 1, padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }} + /> + )} + + )} +
+ ); +}; + +const ConditionRow: React.FC<{ children: React.ReactNode }> = ({ children }) => ( +
+ {children} +
+); diff --git a/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx b/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx index 0b20625..dc8edb2 100644 --- a/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx +++ b/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx @@ -53,6 +53,7 @@ import { ContextBuilderRenderer } from './ContextBuilderRenderer'; import { ContextAssignmentsEditor } from './ContextAssignmentsEditor'; import { FeatureInstancePicker } from './FeatureInstancePicker'; import { UserFileFolderPicker } from './UserFileFolderPicker'; +import { ConditionEditor } from './ConditionEditor'; import { TemplateTextareaRenderer } from './TemplateTextareaRenderer'; import { getApiBaseUrl } from '../../../../../config/config'; @@ -907,30 +908,7 @@ const CronBuilder: React.FC = ({ param, value, onChange, all ); }; -const ConditionBuilder: React.FC = ({ param, value, onChange }) => { - const { t } = useLanguage(); - const cond = (typeof value === 'object' && value !== null) ? value as Record : {}; - const update = (field: string, val: unknown) => onChange({ ...cond, type: 'condition', [field]: val }); - return ( -
- -
- - update('value', e.target.value)} style={{ flex: 2, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} /> -
-
- ); -}; +const ConditionBuilder = ConditionEditor; const MappingTableEditor: React.FC = ({ param, value, onChange }) => { const { t } = useLanguage(); diff --git a/src/components/FlowEditor/nodes/ifElse/IfElseNodeConfig.tsx b/src/components/FlowEditor/nodes/ifElse/IfElseNodeConfig.tsx deleted file mode 100644 index 0cb2e52..0000000 --- a/src/components/FlowEditor/nodes/ifElse/IfElseNodeConfig.tsx +++ /dev/null @@ -1,154 +0,0 @@ -/** - * If/Else node config - inline UI: source dropdown, operator (type-dependent), value. - * Kein Popup, alles in einer Zeile. - */ - -import React from 'react'; -import type { NodeConfigRendererProps } from '../shared/types'; -import { RefSourceSelect, getFieldType } from '../shared/RefSourceSelect'; -import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext'; -import { isRef } 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 StructuredCondition { - type: 'condition'; - ref: { type: 'ref'; nodeId: string; path: (string | number)[] } | null; - operator: string; - value?: string | number; -} - -function parseCondition(v: unknown): StructuredCondition | null { - if (v && typeof v === 'object' && (v as StructuredCondition).type === 'condition') { - const c = v as StructuredCondition; - if (c.ref === null || isRef(c.ref)) return c; - } - return null; -} - -export const IfElseNodeConfig: React.FC = ({ params, updateParam }) => { - const { t } = useLanguage(); - const dataFlow = useAutomation2DataFlow(); - - const cond = parseCondition(params.condition); - const ref = cond?.ref ?? null; - const operator = cond?.operator ?? 'eq'; - const value = cond?.value ?? ''; - - const fieldType = dataFlow ? getFieldType(ref, dataFlow.nodes, dataFlow.nodeOutputsPreview) : 'unknown'; - const operators = operatorsForType(fieldType); - const currentOp = operators.find((o) => o.value === operator) ?? operators[0]; - const needsValue = currentOp?.needsValue ?? true; - - 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 setCondition = (next: StructuredCondition) => { - updateParam('condition', next); - }; - - const handleRefChange = (newRef: { type: 'ref'; nodeId: string; path: (string | number)[] } | null) => { - if (!newRef) { - setCondition({ - type: 'condition', - ref: null, - operator: 'eq', - value: '', - }); - return; - } - const newType = dataFlow ? getFieldType(newRef, dataFlow.nodes, dataFlow.nodeOutputsPreview) : 'unknown'; - const newOps = operatorsForType(newType); - setCondition({ - type: 'condition', - ref: newRef, - operator: newOps[0]?.value ?? 'eq', - value: cond?.value ?? '', - }); - }; - - const handleOperatorChange = (op: string) => { - const opDef = operators.find((o) => o.value === op); - setCondition({ - type: 'condition', - ref: cond?.ref ?? null, - operator: op, - value: opDef?.needsValue ? value : undefined, - }); - }; - - const handleValueChange = (v: string | number) => { - setCondition({ - type: 'condition', - ref: cond?.ref ?? null, - operator, - value: fieldType === 'number' ? (parseFloat(String(v)) || 0) : String(v), - }); - }; - - return ( -
-
- - -
-
- - -
- {needsValue && ( -
- - {mimeTypeOptions.length > 0 ? ( - - ) : ( - - handleValueChange( - fieldType === 'number' ? parseFloat(e.target.value) || 0 : e.target.value - ) - } - placeholder={ - fieldType === 'number' - ? '0' - : fieldType === 'date' - ? 'TT.MM.JJJJ' - : isMimeTypeRef - ? t('z.B. application/pdf') - : t('z.B. ch') - } - /> - )} -
- )} -
- ); -}; diff --git a/src/components/FlowEditor/nodes/ifElse/index.ts b/src/components/FlowEditor/nodes/ifElse/index.ts index c9f658e..e81eee8 100644 --- a/src/components/FlowEditor/nodes/ifElse/index.ts +++ b/src/components/FlowEditor/nodes/ifElse/index.ts @@ -1 +1,2 @@ -export { IfElseNodeConfig } from './IfElseNodeConfig'; +export { ConditionEditor as IfElseNodeConfig } from '../frontendTypeRenderers/ConditionEditor'; +export type { StructuredCondition } from '../frontendTypeRenderers/ConditionEditor'; diff --git a/src/pages/AutomationsDashboardPage.tsx b/src/pages/AutomationsDashboardPage.tsx index 69b464b..7138cd5 100644 --- a/src/pages/AutomationsDashboardPage.tsx +++ b/src/pages/AutomationsDashboardPage.tsx @@ -1177,6 +1177,13 @@ const _FileLinkList: React.FC<{ files: Array<{ id: string; fileName?: string }> ); }; +const _INTERNAL_EXTRACT_FILENAME_SUBSTR = 'extracted_content_transient'; + +/** Hide persisted transient extract JSON from user-facing Workspace file lists */ +function _isHiddenWorkflowArtifactFile(f: { fileName?: string }): boolean { + return (f.fileName ?? '').toLowerCase().includes(_INTERNAL_EXTRACT_FILENAME_SUBSTR); +} + const _ProducedFilesSection: React.FC<{ steps: Array<{ outputFiles?: Array<{ id: string; fileName?: string }> }>; unassignedFiles?: Array<{ id: string; fileName?: string }>; @@ -1186,10 +1193,12 @@ const _ProducedFilesSection: React.FC<{ const allFiles: Array<{ id: string; fileName?: string }> = []; for (const step of steps) { for (const f of step.outputFiles ?? []) { + if (_isHiddenWorkflowArtifactFile(f)) continue; if (!seen.has(f.id)) { seen.add(f.id); allFiles.push(f); } } } for (const f of unassignedFiles ?? []) { + if (_isHiddenWorkflowArtifactFile(f)) continue; if (!seen.has(f.id)) { seen.add(f.id); allFiles.push(f); } } if (!allFiles.length) return null; @@ -1312,8 +1321,8 @@ const _WorkspaceTab: React.FC<_WorkspaceTabProps> = ({ runId, onBack }) => { {steps.map((step) => { const inputData = _stripFileRefKeys(step.inputSnapshot ?? {}); const outputData = _stripFileRefKeys(step.output ?? {}); - const inputFiles = step.inputFiles ?? []; - const outputFiles = step.outputFiles ?? []; + const inputFiles = (step.inputFiles ?? []).filter((f) => !_isHiddenWorkflowArtifactFile(f)); + const outputFiles = (step.outputFiles ?? []).filter((f) => !_isHiddenWorkflowArtifactFile(f)); const hasInput = inputData !== undefined || inputFiles.length > 0; const hasOutput = outputData !== undefined || outputFiles.length > 0; return ( @@ -1374,12 +1383,16 @@ const _WorkspaceTab: React.FC<_WorkspaceTabProps> = ({ runId, onBack }) => { })} )} - {unassignedFiles && unassignedFiles.length > 0 && ( + {(() => { + const visibleUnassigned = (unassignedFiles ?? []).filter((f) => !_isHiddenWorkflowArtifactFile(f)); + if (!visibleUnassigned.length) return null; + return ( <>

{t('Sonstige Dokumente')}

- <_FileLinkList files={unassignedFiles} /> + <_FileLinkList files={visibleUnassigned} /> - )} + ); + })()} ); };