fix: formular node aufgeräumt und files besser sortiert

This commit is contained in:
Ida 2026-05-13 16:58:02 +02:00
parent ef9955257e
commit e7f2272c30
11 changed files with 389 additions and 847 deletions

View file

@ -1187,6 +1187,12 @@
background: rgba(220, 53, 69, 0.1); background: rgba(220, 53, 69, 0.1);
} }
.formFieldOptionsBlock {
margin-top: 0.4rem;
padding-top: 0.45rem;
border-top: 1px solid var(--border-color, #e8e8e8);
}
/* Upload node config */ /* Upload node config */
.uploadNodeConfig { .uploadNodeConfig {
display: flex; display: flex;

View file

@ -0,0 +1,104 @@
/**
* One text field per option the text the end user sees in the dropdown.
* Stored as { value, label } with the same string so payload and UI stay in sync.
*/
import React from 'react';
import { FaTimes } from 'react-icons/fa';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import type { FormFieldOptionRow } from './formFieldOptionsUtils';
export interface FormFieldOptionsEditorProps {
options: FormFieldOptionRow[];
onChange: (next: FormFieldOptionRow[]) => void;
className?: string;
rowClassName?: string;
}
export const FormFieldOptionsEditor: React.FC<FormFieldOptionsEditorProps> = ({
options,
onChange,
className,
rowClassName,
}) => {
const { t } = useLanguage();
const rootClass = className ?? '';
const lineClass = rowClassName ?? '';
const setOptionText = (idx: number, text: string) => {
const next = options.map((o, i) =>
i === idx ? { value: text, label: text } : o,
);
onChange(next);
};
return (
<div className={rootClass}>
<div style={{ fontSize: '0.72rem', color: 'var(--text-secondary, #666)', marginBottom: 4 }}>
{t('Auswahloptionen')}
</div>
{options.map((opt, idx) => (
<div
key={idx}
className={lineClass}
style={{
display: 'flex',
gap: 6,
alignItems: 'center',
marginBottom: 6,
flexWrap: 'wrap',
}}
>
<input
type="text"
placeholder={t('z.B. On hold')}
value={opt.label || opt.value}
onChange={(e) => setOptionText(idx, e.target.value)}
style={{
flex: '1 1 120px',
minWidth: 80,
padding: '4px 6px',
fontSize: '0.8rem',
borderRadius: 4,
border: '1px solid var(--border-color, #ddd)',
boxSizing: 'border-box',
}}
/>
<button
type="button"
title={t('Option entfernen')}
onClick={() => onChange(options.filter((_, i) => i !== idx))}
style={{
padding: '4px 8px',
border: 'none',
background: 'transparent',
color: 'var(--text-tertiary, #999)',
cursor: 'pointer',
borderRadius: 4,
display: 'flex',
alignItems: 'center',
}}
>
<FaTimes />
</button>
</div>
))}
<button
type="button"
onClick={() => onChange([...options, { value: '', label: '' }])}
style={{
marginTop: 2,
padding: '4px 10px',
fontSize: '0.75rem',
borderRadius: 4,
border: '1px dashed var(--border-color, #bbb)',
background: 'var(--bg-primary, #fff)',
color: 'var(--text-secondary, #555)',
cursor: 'pointer',
}}
>
+ {t('Option')}
</button>
</div>
);
};

View file

@ -8,6 +8,12 @@ import type { FormField, NodeConfigRendererProps } from '../shared/types';
import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper'; import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper';
import styles from '../../editor/Automation2FlowEditor.module.css'; import styles from '../../editor/Automation2FlowEditor.module.css';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext'; import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { FormFieldOptionsEditor } from './FormFieldOptionsEditor';
import {
deriveFormFieldPayloadKey,
formFieldTypeHasConfigurableOptions,
normalizeFormFieldOptions,
} from './formFieldOptionsUtils';
import { useLanguage } from '../../../../providers/language/LanguageContext'; import { useLanguage } from '../../../../providers/language/LanguageContext';
@ -64,20 +70,12 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, upda
</span> </span>
<div className={styles.formFieldInputs}> <div className={styles.formFieldInputs}>
<input <input
placeholder={t('name')} placeholder={t('Bezeichnung')}
value={f.name ?? ''}
onChange={(e) => {
const next = [...fields];
next[i] = { ...next[i], name: e.target.value };
updateParam('fields', next);
}}
/>
<input
placeholder={t('label')}
value={f.label ?? ''} value={f.label ?? ''}
onChange={(e) => { onChange={(e) => {
const label = e.target.value;
const next = [...fields]; const next = [...fields];
next[i] = { ...next[i], label: e.target.value }; next[i] = { ...next[i], label, name: deriveFormFieldPayloadKey(label, i) };
updateParam('fields', next); updateParam('fields', next);
}} }}
/> />
@ -88,7 +86,12 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, upda
value={f.type ?? 'text'} value={f.type ?? 'text'}
onChange={(e) => { onChange={(e) => {
const next = [...fields]; const next = [...fields];
next[i] = { name: f.name, label: f.label, type: e.target.value as FormField['type'], required: f.required }; const type = e.target.value as FormField['type'];
const row: FormField = { ...f, type };
if (formFieldTypeHasConfigurableOptions(type)) {
row.options = normalizeFormFieldOptions(row.options);
}
next[i] = row;
updateParam('fields', next); updateParam('fields', next);
}} }}
style={{ width: 'auto', minWidth: 90 }} style={{ width: 'auto', minWidth: 90 }}
@ -118,12 +121,31 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, upda
<FaTimes /> <FaTimes />
</button> </button>
</div> </div>
{formFieldTypeHasConfigurableOptions(f.type) ? (
<FormFieldOptionsEditor
className={styles.formFieldOptionsBlock}
options={normalizeFormFieldOptions(f.options)}
onChange={(opts) => {
const next = [...fields];
next[i] = { ...next[i], options: opts };
updateParam('fields', next);
}}
/>
) : null}
</div> </div>
))} ))}
<button <button
type="button" type="button"
onClick={() => onClick={() =>
updateParam('fields', [...fields, { name: '', type: 'text', label: '', required: false }]) updateParam('fields', [
...fields,
{
name: deriveFormFieldPayloadKey('', fields.length),
type: 'text',
label: '',
required: false,
},
])
} }
> >
+ {t('Feld')} + {t('Feld')}

View file

@ -0,0 +1,40 @@
/**
* Helpers for optional select/multiselect rows on workflow form field definitions.
*/
export type FormFieldOptionRow = { value: string; label: string };
/** Field types where the author defines explicit { value, label } choices. */
export function formFieldTypeHasConfigurableOptions(typeId: string | undefined): boolean {
if (!typeId) return false;
return typeId === 'select' || typeId === 'enum';
}
export function normalizeFormFieldOptions(raw: unknown): FormFieldOptionRow[] {
if (!Array.isArray(raw)) return [];
return raw.map((o, i) => {
if (o && typeof o === 'object' && !Array.isArray(o)) {
const r = o as Record<string, unknown>;
const value = String(r.value ?? r.id ?? '');
const label = String(r.label ?? r.value ?? r.id ?? `Option ${i + 1}`);
return { value, label };
}
const s = String(o ?? '');
return { value: s, label: s };
});
}
/**
* Stable key for `payload.*` / data refs. From the visible label; empty label `field_<index>`.
*/
export function deriveFormFieldPayloadKey(label: string, index: number): string {
const trimmed = label.trim();
if (!trimmed) return `field_${index + 1}`;
const deaccent = trimmed.normalize('NFKD').replace(/[\u0300-\u036f]/g, '');
let s = deaccent
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
if (!s) return `field_${index + 1}`;
return s;
}

View file

@ -1 +1,8 @@
export { FormNodeConfig } from './FormNodeConfig'; export { FormNodeConfig } from './FormNodeConfig';
export { FormFieldOptionsEditor } from './FormFieldOptionsEditor';
export type { FormFieldOptionRow } from './formFieldOptionsUtils';
export {
deriveFormFieldPayloadKey,
formFieldTypeHasConfigurableOptions,
normalizeFormFieldOptions,
} from './formFieldOptionsUtils';

View file

@ -8,6 +8,12 @@ import type { NodeTypeParameter } from '../../../../api/workflowApi';
import type { ApiRequestFunction } from '../../../../api/workflowApi'; import type { ApiRequestFunction } from '../../../../api/workflowApi';
import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper'; import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext'; import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { FormFieldOptionsEditor } from '../form/FormFieldOptionsEditor';
import {
deriveFormFieldPayloadKey,
formFieldTypeHasConfigurableOptions,
normalizeFormFieldOptions,
} from '../form/formFieldOptionsUtils';
export interface FieldRendererProps { export interface FieldRendererProps {
param: NodeTypeParameter; param: NodeTypeParameter;
@ -567,13 +573,35 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
? ctx.formFieldTypes ? ctx.formFieldTypes
: FORM_FIELD_TYPES.map((ft) => ({ id: ft, label: FORM_FIELD_TYPE_LABELS[ft] ?? ft, portType: 'str' })); : FORM_FIELD_TYPES.map((ft) => ({ id: ft, label: FORM_FIELD_TYPE_LABELS[ft] ?? ft, portType: 'str' }));
const fields = Array.isArray(value) ? value : []; const fields = Array.isArray(value) ? value : [];
const addField = () => onChange([...fields, { name: '', type: 'text', label: '', required: false }]); const addField = () => {
const idx = fields.length;
onChange([
...fields,
{ name: deriveFormFieldPayloadKey('', idx), type: 'text', label: '', required: false },
]);
};
const removeField = (idx: number) => onChange(fields.filter((_: unknown, i: number) => i !== idx)); const removeField = (idx: number) => onChange(fields.filter((_: unknown, i: number) => i !== idx));
const updateField = (idx: number, field: string, val: unknown) => { const updateField = (idx: number, field: string, val: unknown) => {
const next = [...fields]; const next = [...fields];
next[idx] = { ...(next[idx] as Record<string, unknown>), [field]: val }; next[idx] = { ...(next[idx] as Record<string, unknown>), [field]: val };
onChange(next); onChange(next);
}; };
const setFieldLabel = (idx: number, label: string) => {
const next = [...fields];
const row = { ...(next[idx] as Record<string, unknown>), label, name: deriveFormFieldPayloadKey(label, idx) };
next[idx] = row;
onChange(next);
};
const setTopFieldType = (idx: number, typeId: string) => {
const next = [...fields];
const cur = { ...(next[idx] as Record<string, unknown>) };
cur.type = typeId;
if (formFieldTypeHasConfigurableOptions(typeId)) {
cur.options = normalizeFormFieldOptions(cur.options);
}
next[idx] = cur;
onChange(next);
};
const inputStyle: React.CSSProperties = { const inputStyle: React.CSSProperties = {
width: '100%', padding: '5px 7px', borderRadius: 4, border: '1px solid #ddd', width: '100%', padding: '5px 7px', borderRadius: 4, border: '1px solid #ddd',
fontSize: 12, boxSizing: 'border-box', background: '#fff', fontSize: 12, boxSizing: 'border-box', background: '#fff',
@ -591,7 +619,7 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
type="text" type="text"
placeholder={t('Bezeichnung (Anzeigename)')} placeholder={t('Bezeichnung (Anzeigename)')}
value={String(f.label ?? '')} value={String(f.label ?? '')}
onChange={(e) => updateField(i, 'label', e.target.value)} onChange={(e) => setFieldLabel(i, e.target.value)}
style={{ ...inputStyle, flex: 1, fontWeight: 500 }} style={{ ...inputStyle, flex: 1, fontWeight: 500 }}
/> />
<button <button
@ -601,21 +629,11 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
style={{ padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', background: '#fff', color: '#999', fontSize: 13, lineHeight: 1, flexShrink: 0 }} style={{ padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', background: '#fff', color: '#999', fontSize: 13, lineHeight: 1, flexShrink: 0 }}
>×</button> >×</button>
</div> </div>
{/* Row 2: Name + Typ + Pflicht */} {/* Row 2: Typ + Pflicht */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr auto', gap: 6, alignItems: 'end' }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 6, alignItems: 'end' }}>
<div>
<div style={{ fontSize: 10, color: '#888', marginBottom: 2 }}>Name (intern)</div>
<input
type="text"
placeholder="z.B. customerName"
value={String(f.name ?? '')}
onChange={(e) => updateField(i, 'name', e.target.value)}
style={inputStyle}
/>
</div>
<div> <div>
<div style={{ fontSize: 10, color: '#888', marginBottom: 2 }}>Typ</div> <div style={{ fontSize: 10, color: '#888', marginBottom: 2 }}>Typ</div>
<select value={String(f.type ?? 'text')} onChange={(e) => updateField(i, 'type', e.target.value)} style={selectStyle}> <select value={String(f.type ?? 'text')} onChange={(e) => setTopFieldType(i, e.target.value)} style={selectStyle}>
{fieldTypeOptions.map((ft) => ( {fieldTypeOptions.map((ft) => (
<option key={ft.id} value={ft.id}>{t(ft.label)}</option> <option key={ft.id} value={ft.id}>{t(ft.label)}</option>
))} ))}
@ -627,6 +645,14 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
Pflicht Pflicht
</label> </label>
</div> </div>
{formFieldTypeHasConfigurableOptions(String(f.type)) ? (
<div style={{ marginTop: 8, borderTop: '1px solid #e0e0e0', paddingTop: 8 }}>
<FormFieldOptionsEditor
options={normalizeFormFieldOptions(f.options)}
onChange={(opts) => updateField(i, 'options', opts)}
/>
</div>
) : null}
{String(f.type) === 'group' && ( {String(f.type) === 'group' && (
<div style={{ marginTop: 8, borderTop: '1px solid #e0e0e0', paddingTop: 8 }}> <div style={{ marginTop: 8, borderTop: '1px solid #e0e0e0', paddingTop: 8 }}>
<div style={{ fontSize: 11, color: '#666', marginBottom: 6, fontWeight: 600 }}>{t('Unterfelder')}</div> <div style={{ fontSize: 11, color: '#666', marginBottom: 6, fontWeight: 600 }}>{t('Unterfelder')}</div>
@ -635,11 +661,16 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}> <div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<input <input
type="text" type="text"
placeholder={t('Name')} placeholder={t('Bezeichnung')}
value={String(sub.name ?? '')} value={String(sub.label ?? sub.name ?? '')}
onChange={(e) => { onChange={(e) => {
const label = e.target.value;
const nextFields = [...(Array.isArray(f.fields) ? f.fields : [])]; const nextFields = [...(Array.isArray(f.fields) ? f.fields : [])];
nextFields[j] = { ...sub, name: e.target.value }; nextFields[j] = {
...sub,
label,
name: deriveFormFieldPayloadKey(label, j),
};
updateField(i, 'fields', nextFields); updateField(i, 'fields', nextFields);
}} }}
style={{ ...inputStyle, flex: 1 }} style={{ ...inputStyle, flex: 1 }}
@ -647,8 +678,13 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
<select <select
value={String(sub.type ?? 'text')} value={String(sub.type ?? 'text')}
onChange={(e) => { onChange={(e) => {
const typeId = e.target.value;
const nextFields = [...(Array.isArray(f.fields) ? f.fields : [])]; const nextFields = [...(Array.isArray(f.fields) ? f.fields : [])];
nextFields[j] = { ...sub, type: e.target.value }; const subRow = { ...(nextFields[j] as Record<string, unknown>), type: typeId };
if (formFieldTypeHasConfigurableOptions(typeId)) {
subRow.options = normalizeFormFieldOptions(subRow.options);
}
nextFields[j] = subRow;
updateField(i, 'fields', nextFields); updateField(i, 'fields', nextFields);
}} }}
style={{ ...selectStyle, flex: 1 }} style={{ ...selectStyle, flex: 1 }}
@ -666,12 +702,31 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
style={{ padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', background: '#fff', color: '#999', flexShrink: 0 }} style={{ padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', background: '#fff', color: '#999', flexShrink: 0 }}
>×</button> >×</button>
</div> </div>
{formFieldTypeHasConfigurableOptions(String(sub.type)) ? (
<div style={{ marginTop: 6 }}>
<FormFieldOptionsEditor
options={normalizeFormFieldOptions(sub.options)}
onChange={(opts) => {
const nextFields = [...(Array.isArray(f.fields) ? f.fields : [])];
nextFields[j] = { ...sub, options: opts };
updateField(i, 'fields', nextFields);
}}
/>
</div>
) : null}
</div> </div>
))} ))}
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
const nextFields = [...(Array.isArray(f.fields) ? f.fields : []), { name: '', type: 'text', label: '', required: false }]; const nextFields = [...(Array.isArray(f.fields) ? f.fields : [])];
const j = nextFields.length;
nextFields.push({
name: deriveFormFieldPayloadKey('', j),
type: 'text',
label: '',
required: false,
});
updateField(i, 'fields', nextFields); updateField(i, 'fields', nextFields);
}} }}
style={{ marginTop: 4, padding: '3px 10px', borderRadius: 4, border: '1px dashed #bbb', cursor: 'pointer', fontSize: 11, background: '#fff', color: '#666' }} style={{ marginTop: 4, padding: '3px 10px', borderRadius: 4, border: '1px dashed #bbb', cursor: 'pointer', fontSize: 11, background: '#fff', color: '#666' }}

View file

@ -1,194 +0,0 @@
// Copyright (c) 2025 Patrick Motsch
// All rights reserved.
//
// Plan #2 — Track A1.2 / A1.3
// T7: DataPicker strict-type filtering (only compatible candidates rendered).
// T8: DataPicker generic object drill-down via wildcard '*' segment when the
// schema declares List[X] of a known X.
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { CanvasConnection, CanvasNode } from '../../editor/FlowCanvas';
import type { NodeType, PortField, PortSchema } from '../../../../api/workflowApi';
import type { DataRef, SystemVarRef } from './dataRef';
vi.mock('../../../../providers/language/LanguageContext', () => ({
useLanguage: () => ({ t: (s: string) => s }),
}));
let _ctxValue: unknown = null;
vi.mock('../../context/Automation2DataFlowContext', () => ({
useAutomation2DataFlow: () => _ctxValue,
}));
import { DataPicker } from './DataPicker';
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
function _field(name: string, type: string): PortField {
return { name, type, description: '', required: false };
}
const _docListSchema: PortSchema = {
name: 'DocumentList',
fields: [
_field('documents', 'List[UdmDocument]'),
_field('count', 'int'),
_field('meta', 'str'),
],
};
const _udmDocumentSchema: PortSchema = {
name: 'UdmDocument',
fields: [
_field('name', 'str'),
_field('mimeType', 'str'),
_field('sizeBytes', 'int'),
],
};
const _portCatalog: Record<string, PortSchema> = {
DocumentList: _docListSchema,
UdmDocument: _udmDocumentSchema,
};
function _setContext(opts: {
consumerNodeId: string;
nodes: CanvasNode[];
connections: CanvasConnection[];
nodeTypes: NodeType[];
}) {
_ctxValue = {
currentNodeId: opts.consumerNodeId,
nodes: opts.nodes,
connections: opts.connections,
nodeTypes: opts.nodeTypes,
portTypeCatalog: _portCatalog,
nodeOutputsPreview: {},
systemVariables: {},
language: 'de',
getNodeLabel: (n: { id: string; title?: string }) => n.title ?? n.id,
getAvailableSourceIds: () => opts.nodes.filter((n) => n.id !== opts.consumerNodeId).map((n) => n.id),
parseGraphDefinedSchema: () => null,
};
}
function _node(id: string, type: string): CanvasNode {
return { id, type, title: id, x: 0, y: 0, inputs: 1, outputs: 1, parameters: {} };
}
function _conn(id: string, src: string, tgt: string): CanvasConnection {
return { id, sourceId: src, sourceHandle: 0, targetId: tgt, targetHandle: 0 };
}
function _nodeType(id: string, outputSchema: string): NodeType {
return {
id,
label: id,
description: id,
category: 'test',
parameters: [],
inputs: 1,
outputs: 1,
outputPorts: [{ schema: outputSchema }],
} as unknown as NodeType;
}
function _renderPicker(props?: { expectedParamType?: string; onPick?: (r: DataRef | SystemVarRef) => void }) {
const upstream = _node('up', 'sharepoint.readDocs');
const consumer = _node('cons', 'ai.summarize');
_setContext({
consumerNodeId: 'cons',
nodes: [upstream, consumer],
connections: [_conn('c1', 'up', 'cons')],
nodeTypes: [_nodeType('sharepoint.readDocs', 'DocumentList'), _nodeType('ai.summarize', 'AiResult')],
});
return render(
<DataPicker
open
onClose={() => {}}
onPick={props?.onPick ?? (() => {})}
availableSourceIds={['up']}
nodes={[upstream]}
nodeOutputsPreview={{}}
getNodeLabel={(n) => n.title ?? n.id}
expectedParamType={props?.expectedParamType}
/>,
);
}
// ---------------------------------------------------------------------------
// T8: Wildcard drill-down
// ---------------------------------------------------------------------------
describe('DataPicker — generic-object drill-down (T8)', () => {
it('renders the wildcard "documents * name" path when drilling into List[UdmDocument]', async () => {
_renderPicker();
await userEvent.click(screen.getByText(/^up$/));
expect(screen.getByText(/documents \* name/)).toBeInTheDocument();
expect(screen.getByText(/documents \* mimeType/)).toBeInTheDocument();
});
it('lists the wholeOutput, top-level fields, and drilled fields together', async () => {
_renderPicker();
await userEvent.click(screen.getByText(/^up$/));
expect(screen.getByText('documents')).toBeInTheDocument();
expect(screen.getByText('count')).toBeInTheDocument();
expect(screen.getByText('meta')).toBeInTheDocument();
// Multiple drilled candidates exist (name, mimeType, sizeBytes, _success, _error).
expect(screen.getAllByText(/documents \*/).length).toBeGreaterThanOrEqual(2);
});
});
// ---------------------------------------------------------------------------
// T7: Strict type filter
// ---------------------------------------------------------------------------
describe('DataPicker — strict type filtering (T7)', () => {
it('hides hard-mismatch fields when expectedParamType is set + strict toggle is on (default)', async () => {
_renderPicker({ expectedParamType: 'str' });
expect(screen.getByLabelText(/Nur kompatible/i)).toBeChecked();
await userEvent.click(screen.getByText(/^up$/));
// documents (List[UdmDocument]) is a hard mismatch → shown with warning (not removed).
expect(screen.getByText('documents')).toBeInTheDocument();
// meta (str) is exact match → kept.
expect(screen.getByText('meta')).toBeInTheDocument();
// count (int) is "coerce" against str → kept (coerce is allowed in strict mode).
expect(screen.getByText('count')).toBeInTheDocument();
// Drilled wildcard candidates of type str (name, mimeType) remain.
expect(screen.getByText(/documents \* name/)).toBeInTheDocument();
});
it('shows all fields after the user disables the strict toggle', async () => {
_renderPicker({ expectedParamType: 'str' });
await userEvent.click(screen.getByLabelText(/Nur kompatible/i));
await userEvent.click(screen.getByText(/^up$/));
expect(screen.getByText('documents')).toBeInTheDocument();
expect(screen.getByText('count')).toBeInTheDocument();
expect(screen.getByText('meta')).toBeInTheDocument();
});
it('shows the iterieren-button on List[X] candidates that match expectedParamType=X (T6)', async () => {
_renderPicker({ expectedParamType: 'UdmDocument' });
await userEvent.click(screen.getByText(/^up$/));
// documents (List[UdmDocument]) is the only candidate with expectedParamType=UdmDocument
expect(screen.getByText('documents')).toBeInTheDocument();
expect(screen.getByText('iterieren')).toBeInTheDocument();
});
it('emits a wildcard ref when the user clicks "iterieren"', async () => {
const onPick = vi.fn();
_renderPicker({ expectedParamType: 'UdmDocument', onPick });
await userEvent.click(screen.getByText(/^up$/));
await userEvent.click(screen.getByText('iterieren'));
expect(onPick).toHaveBeenCalledWith(
expect.objectContaining({
type: 'ref',
nodeId: 'up',
path: ['documents', '*'],
expectedType: 'UdmDocument',
}),
);
});
});

View file

@ -1,243 +0,0 @@
// Copyright (c) 2025 Patrick Motsch
// All rights reserved.
//
// Plan #2 — Track A1.1: Component-level tests for RequiredAttributePicker.
// Validates the 0/1/N rendering logic that orchestrates DataPicker selection
// + the iterierens-suggestion (T5, T6).
//
// We mock the two consumed contexts (LanguageContext + Automation2DataFlow)
// and the DataPicker child so we can assert on the picker UI in isolation.
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { CanvasConnection, CanvasNode } from '../../editor/FlowCanvas';
import type { NodeType, PortField, PortSchema } from '../../../../api/workflowApi';
import type { DataRef, SystemVarRef } from './dataRef';
// ---------------------------------------------------------------------------
// Module mocks — must be registered before importing the SUT
// ---------------------------------------------------------------------------
vi.mock('../../../../providers/language/LanguageContext', () => ({
useLanguage: () => ({ t: (s: string) => s }),
}));
let _ctxValue: unknown = null;
vi.mock('../../context/Automation2DataFlowContext', () => ({
useAutomation2DataFlow: () => _ctxValue,
}));
vi.mock('./DataPicker', () => ({
DataPicker: (props: {
open: boolean;
onClose: () => void;
onPick: (ref: DataRef | SystemVarRef) => void;
}) => {
if (!props.open) return null;
return (
<div data-testid="mock-data-picker">
<button
type="button"
onClick={() => {
props.onPick({ type: 'ref', nodeId: 'picked', path: [], expectedType: 'DocumentList' });
props.onClose();
}}
>
mock-pick
</button>
<button type="button" onClick={props.onClose}>
mock-close
</button>
</div>
);
},
}));
// SUT imported AFTER mocks (so mocks are applied)
import { RequiredAttributePicker } from './RequiredAttributePicker';
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
function _field(name: string, type: string): PortField {
return { name, type, description: '', required: false };
}
const _docListSchema: PortSchema = {
name: 'DocumentList',
fields: [_field('documents', 'List[UdmDocument]'), _field('count', 'int')],
};
const _udmDocumentSchema: PortSchema = {
name: 'UdmDocument',
fields: [_field('name', 'str'), _field('mimeType', 'str')],
};
const _portCatalog: Record<string, PortSchema> = {
DocumentList: _docListSchema,
UdmDocument: _udmDocumentSchema,
};
function _setContext(opts: {
consumerNodeId: string;
nodes: CanvasNode[];
connections: CanvasConnection[];
nodeTypes: NodeType[];
}) {
_ctxValue = {
currentNodeId: opts.consumerNodeId,
nodes: opts.nodes,
connections: opts.connections,
nodeTypes: opts.nodeTypes,
portTypeCatalog: _portCatalog,
nodeOutputsPreview: {},
systemVariables: {},
language: 'de',
getNodeLabel: (n: { id: string; title?: string }) => n.title ?? n.id,
getAvailableSourceIds: () => opts.nodes.map((n) => n.id).filter((id) => id !== opts.consumerNodeId),
parseGraphDefinedSchema: () => null,
};
}
function _node(id: string, type: string): CanvasNode {
return { id, type, title: id, x: 0, y: 0, inputs: 1, outputs: 1, parameters: {} };
}
function _conn(id: string, src: string, tgt: string): CanvasConnection {
return { id, sourceId: src, sourceHandle: 0, targetId: tgt, targetHandle: 0 };
}
function _nodeType(id: string, outputSchema: string): NodeType {
return {
id,
label: id,
description: id,
category: 'test',
parameters: [],
inputs: 1,
outputs: 1,
outputPorts: [{ schema: outputSchema }],
} as unknown as NodeType;
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('RequiredAttributePicker — 0/1/N rendering (T5/T6)', () => {
it('shows red "no source" pill when no upstream candidate matches (0-case)', () => {
_setContext({
consumerNodeId: 'cons',
nodes: [_node('cons', 'ai.summarizeDocument')],
connections: [],
nodeTypes: [_nodeType('ai.summarizeDocument', 'AiResult')],
});
render(
<RequiredAttributePicker
label="Document List"
expectedType="DocumentList"
value={undefined}
onChange={() => {}}
/>,
);
expect(
screen.getByText(/Keine typkompatible Quelle vorhanden/i),
).toBeInTheDocument();
});
it('shows auto-bind suggestion when exactly one candidate matches (1-case)', () => {
_setContext({
consumerNodeId: 'cons',
nodes: [_node('up', 'sharepoint.readDocs'), _node('cons', 'ai.summarizeDocument')],
connections: [_conn('c1', 'up', 'cons')],
nodeTypes: [
_nodeType('sharepoint.readDocs', 'DocumentList'),
_nodeType('ai.summarizeDocument', 'AiResult'),
],
});
render(
<RequiredAttributePicker
label="Document List"
expectedType="DocumentList"
value={undefined}
onChange={() => {}}
/>,
);
expect(screen.getByText(/Vorschlag übernehmen/i)).toBeInTheDocument();
});
it('shows iterieren-suggestion when upstream is List[X] and required is X (T6)', () => {
_setContext({
consumerNodeId: 'cons',
nodes: [_node('up', 'sharepoint.readDocs'), _node('cons', 'ai.summarizeDocument')],
connections: [_conn('c1', 'up', 'cons')],
nodeTypes: [
_nodeType('sharepoint.readDocs', 'DocumentList'),
_nodeType('ai.summarizeDocument', 'AiResult'),
],
});
render(
<RequiredAttributePicker
label="Single document"
expectedType="UdmDocument"
value={undefined}
onChange={() => {}}
/>,
);
expect(screen.getByText(/iterieren/i)).toBeInTheDocument();
});
it('renders bound chip + "Andere wählen" when value is already a DataRef', async () => {
_setContext({
consumerNodeId: 'cons',
nodes: [_node('up', 'sharepoint.readDocs'), _node('cons', 'ai.summarizeDocument')],
connections: [_conn('c1', 'up', 'cons')],
nodeTypes: [
_nodeType('sharepoint.readDocs', 'DocumentList'),
_nodeType('ai.summarizeDocument', 'AiResult'),
],
});
const onChange = vi.fn();
render(
<RequiredAttributePicker
label="Document List"
expectedType="DocumentList"
value={{ type: 'ref', nodeId: 'up', path: [], expectedType: 'DocumentList' }}
onChange={onChange}
/>,
);
expect(screen.getByText('up')).toBeInTheDocument();
const clearButton = screen.getByTitle(/Bindung entfernen/i);
await userEvent.click(clearButton);
expect(onChange).toHaveBeenCalledWith(null);
});
it('opens DataPicker via "Andere wählen" and forwards the picked ref to onChange', async () => {
_setContext({
consumerNodeId: 'cons',
nodes: [_node('up', 'sharepoint.readDocs'), _node('cons', 'ai.summarizeDocument')],
connections: [_conn('c1', 'up', 'cons')],
nodeTypes: [
_nodeType('sharepoint.readDocs', 'DocumentList'),
_nodeType('ai.summarizeDocument', 'AiResult'),
],
});
const onChange = vi.fn();
render(
<RequiredAttributePicker
label="Document List"
expectedType="DocumentList"
value={{ type: 'ref', nodeId: 'up', path: [], expectedType: 'DocumentList' }}
onChange={onChange}
/>,
);
const otherButton = screen.getByText(/Andere wählen…/i);
await userEvent.click(otherButton);
expect(screen.getByTestId('mock-data-picker')).toBeInTheDocument();
await userEvent.click(screen.getByText('mock-pick'));
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({ type: 'ref', nodeId: 'picked', expectedType: 'DocumentList' }),
);
});
});

View file

@ -1,318 +0,0 @@
// Copyright (c) 2025 Patrick Motsch
// All rights reserved.
//
// Plan #2 — Track A1 / FE-Tests
// T5/T6 (RequiredAttributePicker): 0/1/N candidate logic + iterierens-suggestion
// T7 (DataPicker): strict type filtering
// T8 (DataPicker): generic-object drill-down via wildcard segment '*'
//
// We test the pure helpers in paramValidation.ts directly. The component
// pickers are thin shells over these helpers, so covering the helpers covers
// the deterministic core of the binding affordance.
import { describe, expect, it } from 'vitest';
import {
findGraphErrors,
findRequiredErrors,
findSourceCandidates,
isParamBound,
strictlyCompatible,
type SourceCandidate,
} from './paramValidation';
import { createRef, createSystemVar, createValue } from './dataRef';
import type { CanvasConnection, CanvasNode } from '../../editor/FlowCanvas';
import type { NodeType, PortField, PortSchema } from '../../../../api/workflowApi';
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
function _field(name: string, type: string): PortField {
return { name, type, description: '', required: false };
}
function _schema(name: string, fields: PortField[]): PortSchema {
return { name, fields };
}
const _docListSchema: PortSchema = _schema('DocumentList', [
_field('documents', 'List[UdmDocument]'),
_field('count', 'int'),
]);
const _udmDocumentSchema: PortSchema = _schema('UdmDocument', [
_field('name', 'str'),
_field('mimeType', 'str'),
_field('sizeBytes', 'int'),
]);
const _aiResultSchema: PortSchema = _schema('AiResult', [
_field('text', 'str'),
_field('tokensUsed', 'int'),
]);
const _portCatalog: Record<string, PortSchema> = {
DocumentList: _docListSchema,
UdmDocument: _udmDocumentSchema,
AiResult: _aiResultSchema,
};
function _makeNode(id: string, type: string, parameters: Record<string, unknown> = {}): CanvasNode {
return {
id,
type,
title: `${id} (${type})`,
x: 0,
y: 0,
inputs: 1,
outputs: 1,
parameters,
};
}
function _makeConnection(id: string, sourceId: string, targetId: string): CanvasConnection {
return {
id,
sourceId,
sourceHandle: 0,
targetId,
targetHandle: 0,
};
}
function _makeNodeType(
id: string,
outputSchema: string,
parameters: NodeType['parameters'] = [],
): NodeType {
return {
id,
label: id,
description: id,
category: 'test',
parameters,
inputs: 1,
outputs: 1,
outputPorts: [{ schema: outputSchema }],
} as unknown as NodeType;
}
// ---------------------------------------------------------------------------
// isParamBound
// ---------------------------------------------------------------------------
describe('isParamBound', () => {
it('returns false for null/undefined/empty string', () => {
expect(isParamBound(null)).toBe(false);
expect(isParamBound(undefined)).toBe(false);
expect(isParamBound('')).toBe(false);
});
it('returns true for non-empty string/number/boolean', () => {
expect(isParamBound('hello')).toBe(true);
expect(isParamBound(0)).toBe(true);
expect(isParamBound(false)).toBe(true);
});
it('returns true for a valid DataRef and false for one without nodeId', () => {
expect(isParamBound(createRef('node-1', ['x']))).toBe(true);
expect(isParamBound({ type: 'ref', nodeId: '', path: [] })).toBe(false);
});
it('returns true for a SystemVarRef with a variable name', () => {
expect(isParamBound(createSystemVar('user.id'))).toBe(true);
expect(isParamBound({ type: 'system', variable: '' })).toBe(false);
});
it('treats {type:"value", value:""} as unbound but {value:0} as bound', () => {
expect(isParamBound(createValue(''))).toBe(false);
expect(isParamBound(createValue(0))).toBe(true);
expect(isParamBound(createValue([]))).toBe(false);
expect(isParamBound(createValue(['a']))).toBe(true);
});
it('counts non-empty arrays/objects as bound', () => {
expect(isParamBound([])).toBe(false);
expect(isParamBound([1])).toBe(true);
expect(isParamBound({})).toBe(false);
expect(isParamBound({ k: 1 })).toBe(true);
});
});
// ---------------------------------------------------------------------------
// findRequiredErrors / findGraphErrors
// ---------------------------------------------------------------------------
describe('findRequiredErrors', () => {
it('returns empty when all required params are bound', () => {
const node = _makeNode('n1', 'ai.process', {
aiPrompt: 'hello',
documentList: createRef('upstream', ['documents']),
});
const nodeType = _makeNodeType('ai.process', 'AiResult', [
{ name: 'aiPrompt', type: 'str', required: true },
{ name: 'documentList', type: 'DocumentList', required: true },
{ name: 'optional', type: 'str', required: false },
]);
expect(findRequiredErrors(node, nodeType)).toEqual([]);
});
it('flags every unbound required param with its name + type', () => {
const node = _makeNode('n1', 'ai.process', {});
const nodeType = _makeNodeType('ai.process', 'AiResult', [
{ name: 'aiPrompt', type: 'str', required: true },
{ name: 'documentList', type: 'DocumentList', required: true },
{ name: 'optional', type: 'str', required: false },
]);
const errs = findRequiredErrors(node, nodeType);
expect(errs).toHaveLength(2);
expect(errs.map((e) => e.paramName)).toEqual(['aiPrompt', 'documentList']);
});
it('returns empty list when nodeType is unknown', () => {
const node = _makeNode('n1', 'ghost.node');
expect(findRequiredErrors(node, undefined)).toEqual([]);
});
it('skips required params with frontendType="hidden" (UI safety net)', () => {
// Hidden params have no UI surface, so reporting them as
// "Pflichtfeld ohne Quelle" would create a phantom error the user can
// not resolve. They are auto-set by adapters / system defaults.
const node = _makeNode('n1', 'trustee.extractFromFiles', {});
const nodeType = _makeNodeType('trustee.extractFromFiles', 'AiResult', [
{ name: 'prompt', type: 'str', required: true },
{ name: 'systemContext', type: 'str', required: true, frontendType: 'hidden' },
]);
const errs = findRequiredErrors(node, nodeType);
expect(errs).toHaveLength(1);
expect(errs[0]!.paramName).toBe('prompt');
});
});
describe('findGraphErrors', () => {
it('aggregates per-node errors and omits clean nodes', () => {
const cleanNodeType = _makeNodeType('clean.node', 'AiResult', [
{ name: 'p1', type: 'str', required: true },
]);
const dirtyNodeType = _makeNodeType('dirty.node', 'AiResult', [
{ name: 'p1', type: 'str', required: true },
{ name: 'p2', type: 'str', required: true },
]);
const nodes: CanvasNode[] = [
_makeNode('clean', 'clean.node', { p1: 'value' }),
_makeNode('dirty', 'dirty.node', { p1: 'set' }),
];
const result = findGraphErrors(nodes, [cleanNodeType, dirtyNodeType]);
expect(Object.keys(result)).toEqual(['dirty']);
expect(result['dirty']!.map((e) => e.paramName)).toEqual(['p2']);
});
});
// ---------------------------------------------------------------------------
// findSourceCandidates — T5/T6/T7/T8 core
// ---------------------------------------------------------------------------
describe('findSourceCandidates', () => {
function _makeFixture() {
const upstreamType = _makeNodeType('sharepoint.readDocs', 'DocumentList');
const consumerType = _makeNodeType('ai.summarize', 'AiResult', [
{ name: 'documentList', type: 'DocumentList', required: true },
]);
const upstream = _makeNode('up', 'sharepoint.readDocs');
const consumer = _makeNode('cons', 'ai.summarize');
const conns = [_makeConnection('c1', 'up', 'cons')];
return { nodes: [upstream, consumer], connections: conns, nodeTypes: [upstreamType, consumerType] };
}
it('returns the whole-output candidate first (path=[]) for the upstream', () => {
const f = _makeFixture();
const candidates = findSourceCandidates({
consumerNodeId: 'cons',
expectedType: 'DocumentList',
...f,
portTypeCatalog: _portCatalog,
});
const wholeOutput = candidates.find((c) => c.nodeId === 'up' && c.path.length === 0);
expect(wholeOutput).toBeDefined();
expect(wholeOutput!.type).toBe('DocumentList');
expect(wholeOutput!.compat).toBe('ok');
});
it('drills into List[X] elements via wildcard "*" segment (T8 generic drill-down)', () => {
const f = _makeFixture();
const candidates = findSourceCandidates({
consumerNodeId: 'cons',
expectedType: 'str',
...f,
portTypeCatalog: _portCatalog,
});
const wildcardCandidate = candidates.find(
(c) =>
c.nodeId === 'up' &&
c.path[0] === 'documents' &&
c.path[1] === '*' &&
c.path[2] === 'name',
);
expect(wildcardCandidate).toBeDefined();
expect(wildcardCandidate!.type).toBe('str');
expect(wildcardCandidate!.compat).toBe('ok');
});
it('marks List[X] → X as iterable (T6 "iterieren"-Vorschlag)', () => {
const f = _makeFixture();
const candidates = findSourceCandidates({
consumerNodeId: 'cons',
expectedType: 'UdmDocument',
...f,
portTypeCatalog: _portCatalog,
});
const iterable = candidates.find(
(c) => c.nodeId === 'up' && c.path.length === 1 && c.path[0] === 'documents' && c.iterable,
);
expect(iterable).toBeDefined();
expect(iterable!.type).toBe('List[UdmDocument]');
});
it('returns no candidates when no upstream is connected (T5: 0-case)', () => {
const f = _makeFixture();
const isolated = _makeNode('iso', 'ai.summarize');
const candidates = findSourceCandidates({
consumerNodeId: 'iso',
expectedType: 'DocumentList',
nodes: [...f.nodes, isolated],
connections: f.connections,
nodeTypes: f.nodeTypes,
portTypeCatalog: _portCatalog,
});
expect(candidates).toEqual([]);
});
it('returns plain candidates (compat="ok") when expectedType is omitted', () => {
const f = _makeFixture();
const candidates = findSourceCandidates({
consumerNodeId: 'cons',
...f,
portTypeCatalog: _portCatalog,
});
expect(candidates.length).toBeGreaterThan(0);
expect(candidates.every((c) => c.compat === 'ok')).toBe(true);
});
});
// ---------------------------------------------------------------------------
// strictlyCompatible — T7 strict type filter
// ---------------------------------------------------------------------------
describe('strictlyCompatible', () => {
it('keeps only ok / coerce / iterable candidates and drops mismatch', () => {
const all: SourceCandidate[] = [
{ nodeId: 'a', path: [], type: 'DocumentList', compat: 'ok' },
{ nodeId: 'a', path: ['documents'], type: 'List[UdmDocument]', compat: 'mismatch', iterable: true },
{ nodeId: 'a', path: ['count'], type: 'int', compat: 'coerce' },
{ nodeId: 'a', path: ['junk'], type: 'object', compat: 'mismatch' },
];
const out = strictlyCompatible(all);
expect(out.map((c) => c.path)).toEqual([[], ['documents'], ['count']]);
});
});

View file

@ -8,6 +8,12 @@ import type { FormField } from '../shared/types';
import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper'; import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper';
import styles from '../../editor/Automation2FlowEditor.module.css'; import styles from '../../editor/Automation2FlowEditor.module.css';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext'; import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { FormFieldOptionsEditor } from '../form/FormFieldOptionsEditor';
import {
deriveFormFieldPayloadKey,
formFieldTypeHasConfigurableOptions,
normalizeFormFieldOptions,
} from '../form/formFieldOptionsUtils';
import { useLanguage } from '../../../../providers/language/LanguageContext'; import { useLanguage } from '../../../../providers/language/LanguageContext';
@ -21,7 +27,9 @@ function _parseFields(params: Record<string, unknown>, t: (key: string) => strin
const name = String(o.name ?? `field${i + 1}`); const name = String(o.name ?? `field${i + 1}`);
const label = String(o.label ?? `${t('Feld')} ${i + 1}`); const label = String(o.label ?? `${t('Feld')} ${i + 1}`);
const type = (FORM_FIELD_TYPES as readonly string[]).includes(rawType) ? rawType : 'text'; const type = (FORM_FIELD_TYPES as readonly string[]).includes(rawType) ? rawType : 'text';
return { name, label, type } as FormField; const required = Boolean(o.required);
const options = formFieldTypeHasConfigurableOptions(type) ? normalizeFormFieldOptions(o.options) : undefined;
return { name, label, type, required, ...(options !== undefined ? { options } : {}) } as FormField;
} }
return { name: `field${i + 1}`, label: `${t('Feld')} ${i + 1}`, type: 'text' as const }; return { name: `field${i + 1}`, label: `${t('Feld')} ${i + 1}`, type: 'text' as const };
}); });
@ -43,29 +51,19 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
<div className={styles.startNodeDoc}> <div className={styles.startNodeDoc}>
<p className={styles.startNodeDocIntro}> <p className={styles.startNodeDocIntro}>
<strong>{t('Formular-Felder')}</strong>{' '} <strong>{t('Formular-Felder')}</strong>{' '}
{t('werden beim Start ausgefüllt und liegen unter')}{' '} {t('werden beim Start ausgefüllt. Der Payload-Schlüssel wird aus der Beschriftung abgeleitet.')}
<code>payload.&lt;name&gt;</code> {t('in der Start-Ausgabe.')}
</p> </p>
<div className={styles.formFieldsList}> <div className={styles.formFieldsList}>
{fields.map((f, idx) => ( {fields.map((f, idx) => (
<div key={idx} className={styles.formFieldRow}> <div key={idx} className={styles.formFieldRow}>
<input
className={styles.startsInput}
placeholder={t('Name (Payload-Key)')}
value={f.name ?? ''}
onChange={(e) => {
const next = [...fields];
next[idx] = { ...f, name: e.target.value };
setFields(next);
}}
/>
<input <input
className={styles.startsInput} className={styles.startsInput}
placeholder={t('Beschriftung')} placeholder={t('Beschriftung')}
value={f.label ?? ''} value={f.label ?? ''}
onChange={(e) => { onChange={(e) => {
const label = e.target.value;
const next = [...fields]; const next = [...fields];
next[idx] = { ...f, label: e.target.value }; next[idx] = { ...f, label, name: deriveFormFieldPayloadKey(label, idx) };
setFields(next); setFields(next);
}} }}
/> />
@ -74,7 +72,12 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
value={f.type ?? 'text'} value={f.type ?? 'text'}
onChange={(e) => { onChange={(e) => {
const next = [...fields]; const next = [...fields];
next[idx] = { name: f.name, label: f.label, type: e.target.value as FormField['type'] }; const type = e.target.value as FormField['type'];
const row: FormField = { ...f, type };
if (formFieldTypeHasConfigurableOptions(type)) {
row.options = normalizeFormFieldOptions(row.options);
}
next[idx] = row;
setFields(next); setFields(next);
}} }}
> >
@ -89,13 +92,32 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
> >
</button> </button>
{formFieldTypeHasConfigurableOptions(f.type) ? (
<div className={styles.formFieldOptionsBlock}>
<FormFieldOptionsEditor
options={normalizeFormFieldOptions(f.options)}
onChange={(opts) => {
const next = [...fields];
next[idx] = { ...next[idx], options: opts };
setFields(next);
}}
/>
</div>
) : null}
</div> </div>
))} ))}
<button <button
type="button" type="button"
className={styles.startsAddBtn} className={styles.startsAddBtn}
onClick={() => onClick={() =>
setFields([...fields, { name: `field${fields.length + 1}`, label: t('Neues Feld'), type: 'text' }]) setFields([
...fields,
{
name: deriveFormFieldPayloadKey('', fields.length),
label: '',
type: 'text',
},
])
} }
> >
{t('+ Feld')} {t('+ Feld')}

View file

@ -28,6 +28,7 @@ import { Popup } from '../../../components/UiComponents/Popup';
import { getAcceptStringFromConfig, fileMatchesAccept } from '../../../components/FlowEditor'; import { getAcceptStringFromConfig, fileMatchesAccept } from '../../../components/FlowEditor';
import { useFileOperations } from '../../../hooks/useFiles'; import { useFileOperations } from '../../../hooks/useFiles';
import styles from './Automation2WorkflowsTasks.module.css'; import styles from './Automation2WorkflowsTasks.module.css';
import { normalizeFormFieldOptions } from '../../../components/FlowEditor/nodes/form';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
@ -569,6 +570,7 @@ const TaskCard: React.FC<TaskCardProps> = ({
type: string; type: string;
label: string; label: string;
required?: boolean; required?: boolean;
options?: unknown;
clickupConnectionId?: string; clickupConnectionId?: string;
clickupListId?: string; clickupListId?: string;
clickupStatusOptions?: Array<{ value: string; label: string }>; clickupStatusOptions?: Array<{ value: string; label: string }>;
@ -583,8 +585,89 @@ const TaskCard: React.FC<TaskCardProps> = ({
if (f.type === 'clickup_status') { if (f.type === 'clickup_status') {
return v !== undefined && v !== null && String(v).trim() !== ''; return v !== undefined && v !== null && String(v).trim() !== '';
} }
if ((f.type === 'select' || f.type === 'enum') && normalizeFormFieldOptions(f.options).some((o) => String(o.value).trim() !== '')) {
return v !== undefined && v !== null && String(v).trim() !== '';
}
return v !== undefined && v !== null && String(v).trim() !== ''; return v !== undefined && v !== null && String(v).trim() !== '';
}); });
const renderFormControl = (
field: (typeof fields)[number],
): React.ReactNode => {
const selectChoices = normalizeFormFieldOptions(field.options).filter(
(o) => String(o.value).trim() !== '',
);
if (field.type === 'boolean') {
return (
<input
type="checkbox"
checked={(formData[field.name] as boolean) ?? false}
onChange={(e) =>
setFormData((p) => ({ ...p, [field.name]: e.target.checked }))
}
/>
);
}
if (field.type === 'clickup_tasks' && request) {
return (
<InputFormClickupTaskField
connectionId={field.clickupConnectionId ?? ''}
listId={field.clickupListId ?? ''}
value={formData[field.name]}
onChange={(v) => setFormData((p) => ({ ...p, [field.name]: v }))}
request={request}
/>
);
}
if (
field.type === 'clickup_status' &&
Array.isArray(field.clickupStatusOptions) &&
field.clickupStatusOptions.length > 0
) {
return (
<select
value={(formData[field.name] as string) ?? ''}
onChange={(e) =>
setFormData((p) => ({ ...p, [field.name]: e.target.value }))
}
>
<option value="">{t('Status wählen')}</option>
{field.clickupStatusOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
);
}
if ((field.type === 'select' || field.type === 'enum') && selectChoices.length > 0) {
return (
<select
value={(formData[field.name] as string) ?? ''}
onChange={(e) =>
setFormData((p) => ({ ...p, [field.name]: e.target.value }))
}
>
<option value="">{t('Bitte wählen')}</option>
{selectChoices.map((o) => (
<option key={o.value} value={o.value}>
{o.label || o.value}
</option>
))}
</select>
);
}
return (
<input
type={
field.type === 'number' ? 'number' : field.type === 'date' ? 'date' : 'text'
}
value={(formData[field.name] as string) ?? ''}
onChange={(e) =>
setFormData((p) => ({ ...p, [field.name]: e.target.value }))
}
/>
);
};
const formContent = ( const formContent = (
<div className={styles.formFields}> <div className={styles.formFields}>
{fields.map((f) => ( {fields.map((f) => (
@ -593,49 +676,7 @@ const TaskCard: React.FC<TaskCardProps> = ({
{f.label || f.name} {f.label || f.name}
{f.required && ' *'} {f.required && ' *'}
</label> </label>
{f.type === 'boolean' ? ( {renderFormControl(f)}
<input
type="checkbox"
checked={(formData[f.name] as boolean) ?? false}
onChange={(e) =>
setFormData((p) => ({ ...p, [f.name]: e.target.checked }))
}
/>
) : f.type === 'clickup_tasks' && request ? (
<InputFormClickupTaskField
connectionId={f.clickupConnectionId ?? ''}
listId={f.clickupListId ?? ''}
value={formData[f.name]}
onChange={(v) => setFormData((p) => ({ ...p, [f.name]: v }))}
request={request}
/>
) : f.type === 'clickup_status' &&
Array.isArray(f.clickupStatusOptions) &&
f.clickupStatusOptions.length > 0 ? (
<select
value={(formData[f.name] as string) ?? ''}
onChange={(e) =>
setFormData((p) => ({ ...p, [f.name]: e.target.value }))
}
>
<option value="">{t('Status wählen')}</option>
{f.clickupStatusOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
) : (
<input
type={
f.type === 'number' ? 'number' : f.type === 'date' ? 'date' : 'text'
}
value={(formData[f.name] as string) ?? ''}
onChange={(e) =>
setFormData((p) => ({ ...p, [f.name]: e.target.value }))
}
/>
)}
</div> </div>
))} ))}
</div> </div>