fix: handover fix, if/else node extended comparison mode

This commit is contained in:
Ida 2026-05-14 18:38:44 +02:00
parent 587dad5cf9
commit b5084c028e
8 changed files with 302 additions and 187 deletions

View file

@ -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<string, PortSchema>;
conditionOperatorCatalog?: Record<string, ConditionOperatorDef[]>;
systemVariables?: Record<string, SystemVariable>;
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<ConditionMetaResponse> {
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[],
};
}
/**

View file

@ -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<string, SystemVariable>;
/** 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<string, ConditionOperatorDef[]>;
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<string, PortSchema>;
systemVariables?: Record<string, SystemVariable>;
formFieldTypes?: FormFieldType[];
conditionOperatorCatalog?: Record<string, ConditionOperatorDef[]>;
instanceId?: string;
request?: ApiRequestFunction;
children: React.ReactNode;
@ -59,6 +62,7 @@ export const Automation2DataFlowProvider: React.FC<Automation2DataFlowProviderPr
portTypeCatalog = {},
systemVariables = {},
formFieldTypes = [],
conditionOperatorCatalog = {},
instanceId,
request,
children,
@ -120,6 +124,7 @@ export const Automation2DataFlowProvider: React.FC<Automation2DataFlowProviderPr
portTypeCatalog,
systemVariables,
formFieldTypes,
conditionOperatorCatalog,
getNodeLabel: (n: { id: string; title?: string; label?: string; type?: string }) =>
n.title ?? n.label ?? n.type ?? n.id,
getAvailableSourceIds: () => getAvailableSources(node.id, nodes, connections),
@ -127,7 +132,7 @@ export const Automation2DataFlowProvider: React.FC<Automation2DataFlowProviderPr
request,
parseGraphDefinedSchema,
};
}, [node, nodes, connections, nodeOutputsPreview, nodeTypes, language, portTypeCatalog, systemVariables, formFieldTypes, instanceId, request]);
}, [node, nodes, connections, nodeOutputsPreview, nodeTypes, language, portTypeCatalog, systemVariables, formFieldTypes, conditionOperatorCatalog, instanceId, request]);
return (
<Automation2DataFlowContext.Provider value={value}>

View file

@ -111,6 +111,9 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
const [portTypeCatalog, setPortTypeCatalog] = useState<Record<string, unknown>>({});
const [systemVariables, setSystemVariables] = useState<Record<string, unknown>>({});
const [formFieldTypes, setFormFieldTypes] = useState<import('../../../api/workflowApi').FormFieldType[]>([]);
const [conditionOperatorCatalog, setConditionOperatorCatalog] = useState<
Record<string, import('../../../api/workflowApi').ConditionOperatorDef[]>
>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState('');
@ -545,6 +548,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ 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<Automation2FlowEditorProps> = ({ in
portTypeCatalog={portTypeCatalog as Record<string, never>}
systemVariables={systemVariables as Record<string, never>}
formFieldTypes={formFieldTypes}
conditionOperatorCatalog={conditionOperatorCatalog}
instanceId={instanceId}
request={request}
>

View file

@ -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<string, ConditionOperatorDef[]> | undefined,
valueKind: string
): ConditionOperatorDef[] {
if (!catalog) return [];
return catalog[valueKind] ?? catalog.unknown ?? [];
}
export const ConditionEditor: 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 ?? 'Item')
: 'Item';
const itemRef = allParams?.[dependsOn];
const ref: DataRef | null = isRef(itemRef) ? itemRef : null;
const cond = parseCondition(value);
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) {
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 (
<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 ein Item im Data Picker wählen')}
</p>
</div>
);
}
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 (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 4 }}>
{param.description || param.name}
</label>
<ConditionRow>
<label>{t('Vergleich')}</label>
<select
value={cond.operator}
onChange={(e) => handleOperatorChange(e.target.value)}
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>
</ConditionRow>
{loading && (
<div style={{ fontSize: 11, color: '#888', marginBottom: 4 }}>{t('Lade Operatoren…')}</div>
)}
{needsValue && (
<ConditionRow>
<label>{t('Wert')}</label>
{valueInput?.kind === 'select' ||
valueInput?.kind === 'contentType' ||
valueInput?.kind === 'outputMode' ||
valueInput?.kind === 'language' ||
valueInput?.kind === 'mime' ? (
<select
value={String(cond.value ?? '')}
onChange={(e) => handleValueChange(e.target.value)}
style={{ flex: 1, 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>
) : (
<input
type={
valueInput?.kind === 'number'
? 'number'
: valueInput?.kind === 'date'
? 'date'
: 'text'
}
value={String(cond.value ?? '')}
onChange={(e) =>
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' }}
/>
)}
</ConditionRow>
)}
</div>
);
};
const ConditionRow: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 6, fontSize: 12 }}>
{children}
</div>
);

View file

@ -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<FieldRendererProps> = ({ param, value, onChange, all
);
};
const ConditionBuilder: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage();
const cond = (typeof value === 'object' && value !== null) ? value as Record<string, unknown> : {};
const update = (field: string, val: unknown) => onChange({ ...cond, type: 'condition', [field]: val });
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
<div style={{ display: 'flex', gap: 4 }}>
<select value={String(cond.operator ?? 'eq')} onChange={(e) => update('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="gt">{t('größer als')}</option>
<option value="lt">{t('kleiner als')}</option>
<option value="contains">{t('enthält')}</option>
<option value="empty">{t('ist leer')}</option>
<option value="not_empty">{t('ist nicht leer')}</option>
<option value="is_true">{t('ist wahr')}</option>
<option value="is_false">{t('ist falsch')}</option>
</select>
<input type="text" placeholder={t('Wert')} value={String(cond.value ?? '')} onChange={(e) => update('value', e.target.value)} style={{ flex: 2, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
</div>
</div>
);
};
const ConditionBuilder = ConditionEditor;
const MappingTableEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage();

View file

@ -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<NodeConfigRendererProps> = ({ 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<string, unknown> }) => n.id === ref.nodeId)
: null;
const mimeTypeOptions =
isMimeTypeRef && sourceNode?.type === 'input.upload' && sourceNode.parameters
? getMimeTypeOptionsFromUploadParams(sourceNode.parameters as Record<string, unknown>)
: [];
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 (
<div className={styles.ifElseConditionEditor}>
<div className={styles.ifElseConditionRow}>
<label>{t('Datenquelle')}</label>
<RefSourceSelect value={ref} onChange={handleRefChange} placeholder={t('Formularfeld wählen')} />
</div>
<div className={styles.ifElseConditionRow}>
<label>Vergleich</label>
<select value={operator} onChange={(e) => handleOperatorChange(e.target.value)}>
{operators.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
{needsValue && (
<div className={styles.ifElseConditionRow}>
<label>{t('Wert')}</label>
{mimeTypeOptions.length > 0 ? (
<select
value={String(value ?? '')}
onChange={(e) => handleValueChange(e.target.value)}
>
<option value="">{t('MIME-Typ wählen')}</option>
{mimeTypeOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label} ({o.value})
</option>
))}
</select>
) : (
<input
type={fieldType === 'number' ? 'number' : fieldType === 'date' ? 'date' : 'text'}
value={String(value ?? '')}
onChange={(e) =>
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')
}
/>
)}
</div>
)}
</div>
);
};

View file

@ -1 +1,2 @@
export { IfElseNodeConfig } from './IfElseNodeConfig';
export { ConditionEditor as IfElseNodeConfig } from '../frontendTypeRenderers/ConditionEditor';
export type { StructuredCondition } from '../frontendTypeRenderers/ConditionEditor';

View file

@ -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 }) => {
})}
</div>
)}
{unassignedFiles && unassignedFiles.length > 0 && (
{(() => {
const visibleUnassigned = (unassignedFiles ?? []).filter((f) => !_isHiddenWorkflowArtifactFile(f));
if (!visibleUnassigned.length) return null;
return (
<>
<h4 style={{ margin: '1rem 0 0.5rem' }}>{t('Sonstige Dokumente')}</h4>
<_FileLinkList files={unassignedFiles} />
<_FileLinkList files={visibleUnassigned} />
</>
)}
);
})()}
</div>
);
};