fix: formular node aufgeräumt und files besser sortiert
This commit is contained in:
parent
ef9955257e
commit
e7f2272c30
11 changed files with 389 additions and 847 deletions
|
|
@ -1187,6 +1187,12 @@
|
|||
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 */
|
||||
.uploadNodeConfig {
|
||||
display: flex;
|
||||
|
|
|
|||
104
src/components/FlowEditor/nodes/form/FormFieldOptionsEditor.tsx
Normal file
104
src/components/FlowEditor/nodes/form/FormFieldOptionsEditor.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -8,6 +8,12 @@ import type { FormField, NodeConfigRendererProps } from '../shared/types';
|
|||
import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper';
|
||||
import styles from '../../editor/Automation2FlowEditor.module.css';
|
||||
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
|
||||
import { FormFieldOptionsEditor } from './FormFieldOptionsEditor';
|
||||
import {
|
||||
deriveFormFieldPayloadKey,
|
||||
formFieldTypeHasConfigurableOptions,
|
||||
normalizeFormFieldOptions,
|
||||
} from './formFieldOptionsUtils';
|
||||
|
||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||
|
||||
|
|
@ -64,20 +70,12 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, upda
|
|||
</span>
|
||||
<div className={styles.formFieldInputs}>
|
||||
<input
|
||||
placeholder={t('name')}
|
||||
value={f.name ?? ''}
|
||||
onChange={(e) => {
|
||||
const next = [...fields];
|
||||
next[i] = { ...next[i], name: e.target.value };
|
||||
updateParam('fields', next);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
placeholder={t('label')}
|
||||
placeholder={t('Bezeichnung')}
|
||||
value={f.label ?? ''}
|
||||
onChange={(e) => {
|
||||
const label = e.target.value;
|
||||
const next = [...fields];
|
||||
next[i] = { ...next[i], label: e.target.value };
|
||||
next[i] = { ...next[i], label, name: deriveFormFieldPayloadKey(label, i) };
|
||||
updateParam('fields', next);
|
||||
}}
|
||||
/>
|
||||
|
|
@ -88,7 +86,12 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, upda
|
|||
value={f.type ?? 'text'}
|
||||
onChange={(e) => {
|
||||
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);
|
||||
}}
|
||||
style={{ width: 'auto', minWidth: 90 }}
|
||||
|
|
@ -118,12 +121,31 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, upda
|
|||
<FaTimes />
|
||||
</button>
|
||||
</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>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
updateParam('fields', [...fields, { name: '', type: 'text', label: '', required: false }])
|
||||
updateParam('fields', [
|
||||
...fields,
|
||||
{
|
||||
name: deriveFormFieldPayloadKey('', fields.length),
|
||||
type: 'text',
|
||||
label: '',
|
||||
required: false,
|
||||
},
|
||||
])
|
||||
}
|
||||
>
|
||||
+ {t('Feld')}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1 +1,8 @@
|
|||
export { FormNodeConfig } from './FormNodeConfig';
|
||||
export { FormFieldOptionsEditor } from './FormFieldOptionsEditor';
|
||||
export type { FormFieldOptionRow } from './formFieldOptionsUtils';
|
||||
export {
|
||||
deriveFormFieldPayloadKey,
|
||||
formFieldTypeHasConfigurableOptions,
|
||||
normalizeFormFieldOptions,
|
||||
} from './formFieldOptionsUtils';
|
||||
|
|
|
|||
|
|
@ -8,6 +8,12 @@ import type { NodeTypeParameter } from '../../../../api/workflowApi';
|
|||
import type { ApiRequestFunction } from '../../../../api/workflowApi';
|
||||
import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper';
|
||||
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
|
||||
import { FormFieldOptionsEditor } from '../form/FormFieldOptionsEditor';
|
||||
import {
|
||||
deriveFormFieldPayloadKey,
|
||||
formFieldTypeHasConfigurableOptions,
|
||||
normalizeFormFieldOptions,
|
||||
} from '../form/formFieldOptionsUtils';
|
||||
|
||||
export interface FieldRendererProps {
|
||||
param: NodeTypeParameter;
|
||||
|
|
@ -567,13 +573,35 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
|
|||
? ctx.formFieldTypes
|
||||
: FORM_FIELD_TYPES.map((ft) => ({ id: ft, label: FORM_FIELD_TYPE_LABELS[ft] ?? ft, portType: 'str' }));
|
||||
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 updateField = (idx: number, field: string, val: unknown) => {
|
||||
const next = [...fields];
|
||||
next[idx] = { ...(next[idx] as Record<string, unknown>), [field]: val };
|
||||
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 = {
|
||||
width: '100%', padding: '5px 7px', borderRadius: 4, border: '1px solid #ddd',
|
||||
fontSize: 12, boxSizing: 'border-box', background: '#fff',
|
||||
|
|
@ -591,7 +619,7 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
|
|||
type="text"
|
||||
placeholder={t('Bezeichnung (Anzeigename)')}
|
||||
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 }}
|
||||
/>
|
||||
<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 }}
|
||||
>×</button>
|
||||
</div>
|
||||
{/* Row 2: Name + Typ + Pflicht */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 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>
|
||||
{/* Row 2: Typ + Pflicht */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 6, alignItems: 'end' }}>
|
||||
<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) => (
|
||||
<option key={ft.id} value={ft.id}>{t(ft.label)}</option>
|
||||
))}
|
||||
|
|
@ -627,6 +645,14 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
|
|||
Pflicht
|
||||
</label>
|
||||
</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' && (
|
||||
<div style={{ marginTop: 8, borderTop: '1px solid #e0e0e0', paddingTop: 8 }}>
|
||||
<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' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('Name')}
|
||||
value={String(sub.name ?? '')}
|
||||
placeholder={t('Bezeichnung')}
|
||||
value={String(sub.label ?? sub.name ?? '')}
|
||||
onChange={(e) => {
|
||||
const label = e.target.value;
|
||||
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);
|
||||
}}
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
|
|
@ -647,8 +678,13 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
|
|||
<select
|
||||
value={String(sub.type ?? 'text')}
|
||||
onChange={(e) => {
|
||||
const typeId = e.target.value;
|
||||
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);
|
||||
}}
|
||||
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 }}
|
||||
>×</button>
|
||||
</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>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
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);
|
||||
}}
|
||||
style={{ marginTop: 4, padding: '3px 10px', borderRadius: 4, border: '1px dashed #bbb', cursor: 'pointer', fontSize: 11, background: '#fff', color: '#666' }}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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']]);
|
||||
});
|
||||
});
|
||||
|
|
@ -8,6 +8,12 @@ import type { FormField } from '../shared/types';
|
|||
import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper';
|
||||
import styles from '../../editor/Automation2FlowEditor.module.css';
|
||||
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
|
||||
import { FormFieldOptionsEditor } from '../form/FormFieldOptionsEditor';
|
||||
import {
|
||||
deriveFormFieldPayloadKey,
|
||||
formFieldTypeHasConfigurableOptions,
|
||||
normalizeFormFieldOptions,
|
||||
} from '../form/formFieldOptionsUtils';
|
||||
|
||||
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 label = String(o.label ?? `${t('Feld')} ${i + 1}`);
|
||||
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 };
|
||||
});
|
||||
|
|
@ -43,29 +51,19 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
|
|||
<div className={styles.startNodeDoc}>
|
||||
<p className={styles.startNodeDocIntro}>
|
||||
<strong>{t('Formular-Felder')}</strong>{' '}
|
||||
{t('werden beim Start ausgefüllt und liegen unter')}{' '}
|
||||
<code>payload.<name></code> {t('in der Start-Ausgabe.')}
|
||||
{t('werden beim Start ausgefüllt. Der Payload-Schlüssel wird aus der Beschriftung abgeleitet.')}
|
||||
</p>
|
||||
<div className={styles.formFieldsList}>
|
||||
{fields.map((f, idx) => (
|
||||
<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
|
||||
className={styles.startsInput}
|
||||
placeholder={t('Beschriftung')}
|
||||
value={f.label ?? ''}
|
||||
onChange={(e) => {
|
||||
const label = e.target.value;
|
||||
const next = [...fields];
|
||||
next[idx] = { ...f, label: e.target.value };
|
||||
next[idx] = { ...f, label, name: deriveFormFieldPayloadKey(label, idx) };
|
||||
setFields(next);
|
||||
}}
|
||||
/>
|
||||
|
|
@ -74,7 +72,12 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
|
|||
value={f.type ?? 'text'}
|
||||
onChange={(e) => {
|
||||
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);
|
||||
}}
|
||||
>
|
||||
|
|
@ -89,13 +92,32 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
|
|||
>
|
||||
✕
|
||||
</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>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.startsAddBtn}
|
||||
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')}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import { Popup } from '../../../components/UiComponents/Popup';
|
|||
import { getAcceptStringFromConfig, fileMatchesAccept } from '../../../components/FlowEditor';
|
||||
import { useFileOperations } from '../../../hooks/useFiles';
|
||||
import styles from './Automation2WorkflowsTasks.module.css';
|
||||
import { normalizeFormFieldOptions } from '../../../components/FlowEditor/nodes/form';
|
||||
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
|
||||
|
|
@ -569,6 +570,7 @@ const TaskCard: React.FC<TaskCardProps> = ({
|
|||
type: string;
|
||||
label: string;
|
||||
required?: boolean;
|
||||
options?: unknown;
|
||||
clickupConnectionId?: string;
|
||||
clickupListId?: string;
|
||||
clickupStatusOptions?: Array<{ value: string; label: string }>;
|
||||
|
|
@ -583,8 +585,89 @@ const TaskCard: React.FC<TaskCardProps> = ({
|
|||
if (f.type === 'clickup_status') {
|
||||
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() !== '';
|
||||
});
|
||||
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 = (
|
||||
<div className={styles.formFields}>
|
||||
{fields.map((f) => (
|
||||
|
|
@ -593,49 +676,7 @@ const TaskCard: React.FC<TaskCardProps> = ({
|
|||
{f.label || f.name}
|
||||
{f.required && ' *'}
|
||||
</label>
|
||||
{f.type === 'boolean' ? (
|
||||
<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 }))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{renderFormControl(f)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue