feat: extract content node angepasst für mehr optionen

This commit is contained in:
Ida 2026-05-14 13:06:31 +02:00
parent bef2aa8b83
commit 919ad061e1
2 changed files with 429 additions and 83 deletions

View file

@ -15,6 +15,82 @@ import { useAutomation2DataFlow } from '../context/Automation2DataFlowContext';
import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
import { AccordionList } from '../../UiComponents/AccordionList';
import type { AccordionListItem } from '../../UiComponents/AccordionList';
const CONTEXT_EXTRACT_CONTENT_NODE_TYPE = 'context.extractContent';
const CONTEXT_EXTRACT_CHUNK_PARAM_NAMES = ['chunkSizeUnit', 'chunkSize', 'chunkOverlap'] as const;
const CONTEXT_EXTRACT_CHUNK_SET = new Set<string>(CONTEXT_EXTRACT_CHUNK_PARAM_NAMES);
/** Optional params use stored value only (unset ⇒ no chip). Required uses schema default as fallback. */
export function workflowParamUiValue(stored: Record<string, unknown>, param: NodeTypeParameter): unknown {
const raw = stored[param.name];
if (param.required) {
return raw !== undefined && raw !== null ? raw : param.default;
}
return raw;
}
function effectiveSchemaParamString(name: string, currentParams: Record<string, unknown>, nt: NodeType): string {
const raw = currentParams[name];
const s = raw !== undefined && raw !== null ? String(raw) : '';
if (s !== '') return s;
const meta = nt.parameters?.find((p) => p.name === name);
const d = meta?.default;
return d !== undefined && d !== null ? String(d) : '';
}
function accordionExtractParamTitle(param: NodeTypeParameter, t: (key: string) => string): React.ReactNode {
return (
<span style={{ fontWeight: 700, fontSize: 12 }}>
{param.required ? (
<span style={{ color: 'var(--danger-color, #dc3545)', marginRight: 3 }} title={t('Pflichtfeld')}>
*
</span>
) : null}
{param.name}
</span>
);
}
function verboseSchemaTypeBadge(
verboseSchema: boolean,
param: NodeTypeParameter,
t: (key: string) => string,
): React.ReactElement | null {
if (!verboseSchema || !param.type) return null;
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
marginBottom: 6,
flexWrap: 'wrap',
minWidth: 0,
}}
>
<span
title={t('Parameter-Typ')}
style={{
fontSize: 10,
fontWeight: 600,
color: 'var(--text-secondary)',
background: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
borderRadius: 4,
padding: '1px 6px',
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{param.type}
</span>
</div>
);
}
interface NodeConfigPanelProps {
node: CanvasNode | null;
@ -30,6 +106,35 @@ interface NodeConfigPanelProps {
verboseSchema?: boolean;
}
/** When ``frontendOptions.dependsOn`` and ``frontendOptions.showWhen`` are set
* (same convention as trustee / gateway nodeAdapter ``visibleWhen``), hide the
* parameter unless the referenced parameter's effective value matches.
*/
export function parameterVisibleForFrontendOptions(
param: NodeTypeParameter,
params: Record<string, unknown>,
nodeType: NodeType,
): boolean {
const fo = param.frontendOptions;
if (!fo || typeof fo !== 'object') return true;
const dependsOnRaw = fo.dependsOn as unknown;
const showWhenRaw = fo.showWhen as unknown;
if (typeof dependsOnRaw !== 'string' || dependsOnRaw.length === 0 || showWhenRaw === undefined || showWhenRaw === null) {
return true;
}
const depMeta = nodeType.parameters?.find((p) => p.name === dependsOnRaw);
const rawSibling = params[dependsOnRaw];
const siblingValue =
rawSibling !== undefined && rawSibling !== null ? String(rawSibling) : '';
const fallback =
depMeta?.default !== undefined && depMeta?.default !== null ? String(depMeta.default) : '';
const effective = siblingValue !== '' ? siblingValue : fallback;
const allowed: string[] = Array.isArray(showWhenRaw)
? showWhenRaw.map((x) => String(x))
: [String(showWhenRaw)];
return allowed.includes(effective);
}
export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
nodeType,
language,
@ -62,7 +167,12 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
const updateParam = useCallback(
(key: string, value: unknown) => {
setParams((prev) => {
const next = { ...prev, [key]: value };
const next = { ...prev };
if (value === undefined) {
delete next[key];
} else {
next[key] = value;
}
const id = nodeIdRef.current;
if (id) {
if (notifyParentTimeoutRef.current != null) {
@ -135,6 +245,139 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
.join('\n');
}, [requiredErrors, nodeType, language]);
const extractContentAccordionItems = useMemo((): AccordionListItem<string>[] | null => {
if (!node || !nodeType || node.type !== CONTEXT_EXTRACT_CONTENT_NODE_TYPE) return null;
const byName = new Map((nodeType.parameters ?? []).map((p) => [p.name, p]));
const out: AccordionListItem<string>[] = [];
for (const param of sortedParameters) {
if (param.frontendType === 'hidden') continue;
if (CONTEXT_EXTRACT_CHUNK_SET.has(param.name)) continue;
if (!parameterVisibleForFrontendOptions(param, params, nodeType)) continue;
const usePicker = _shouldUseRequiredPicker(param);
if (usePicker) {
out.push({
id: param.name,
title: accordionExtractParamTitle(param, t),
children: (
<div style={{ minWidth: 0 }}>
{verboseSchemaTypeBadge(verboseSchema, param, t)}
<RequiredAttributePicker
label={getLabel(param.description, language) || param.name}
expectedType={param.type}
value={workflowParamUiValue(params, param)}
onChange={(val) => updateParam(param.name, val)}
/>
</div>
),
});
continue;
}
const frontendType = param.frontendType || 'text';
const Renderer = FRONTEND_TYPE_RENDERERS[frontendType] ?? FRONTEND_TYPE_RENDERERS.text;
if (param.name === 'outputMode') {
const chunksNested = effectiveSchemaParamString('outputMode', params, nodeType) === 'chunks';
out.push({
id: param.name,
title: accordionExtractParamTitle(param, t),
children: (
<div style={{ minWidth: 0 }}>
{verboseSchemaTypeBadge(verboseSchema, param, t)}
<Renderer
param={param}
value={workflowParamUiValue(params, param)}
onChange={(val: unknown) => updateParam(param.name, val)}
allParams={params}
instanceId={instanceId}
request={request}
nodeType={node.type}
onPatchParams={patchParams}
hideAccordionTitle
/>
{chunksNested ? (
<div style={{ marginTop: 8 }}>
<AccordionList<string>
key={`extract-chunks-${node.id}`}
defaultOpenId={null}
items={CONTEXT_EXTRACT_CHUNK_PARAM_NAMES.map((chunkName): AccordionListItem<string> => {
const cp = byName.get(chunkName);
if (!cp) {
return { id: chunkName, title: chunkName, children: <></> };
}
const ft = cp.frontendType || 'text';
const ChunkRenderer = FRONTEND_TYPE_RENDERERS[ft] ?? FRONTEND_TYPE_RENDERERS.text;
return {
id: chunkName,
title: accordionExtractParamTitle(cp, t),
children: (
<div style={{ minWidth: 0 }}>
{verboseSchemaTypeBadge(verboseSchema, cp, t)}
<ChunkRenderer
param={cp}
value={workflowParamUiValue(params, cp)}
onChange={(val: unknown) => updateParam(cp.name, val)}
allParams={params}
instanceId={instanceId}
request={request}
nodeType={node.type}
onPatchParams={patchParams}
hideAccordionTitle
/>
</div>
),
};
})}
/>
</div>
) : null}
</div>
),
});
continue;
}
out.push({
id: param.name,
title: accordionExtractParamTitle(param, t),
children: (
<div style={{ minWidth: 0 }}>
{verboseSchemaTypeBadge(verboseSchema, param, t)}
<Renderer
param={param}
value={workflowParamUiValue(params, param)}
onChange={(val: unknown) => updateParam(param.name, val)}
allParams={params}
instanceId={instanceId}
request={request}
nodeType={node.type}
onPatchParams={patchParams}
hideAccordionTitle
/>
</div>
),
});
}
return out;
}, [
sortedParameters,
params,
nodeType,
language,
node?.id,
node?.type,
verboseSchema,
instanceId,
request,
patchParams,
updateParam,
t,
]);
if (!node || !nodeType) return null;
const isTrigger = node.type.startsWith('trigger.');
@ -239,79 +482,88 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
<strong>{requiredErrors.map((e) => e.paramLabel).join(', ')}</strong>
</div>
)}
{parameters.map((param: NodeTypeParameter) => {
// Safety net: hidden params have no UI footprint at all — no row,
// no required-mark, no type-badge. Their value is system-set.
if (param.frontendType === 'hidden') return null;
const useRequiredPicker = _shouldUseRequiredPicker(param);
if (useRequiredPicker) {
{extractContentAccordionItems !== null ? (
<AccordionList<string>
key={`${node.id}-extract-accordion`}
defaultOpenId={null}
items={extractContentAccordionItems}
/>
) : (
parameters.map((param: NodeTypeParameter) => {
// Safety net: hidden params have no UI footprint at all — no row,
// no required-mark, no type-badge. Their value is system-set.
if (param.frontendType === 'hidden') return null;
if (!parameterVisibleForFrontendOptions(param, params, nodeType)) return null;
const useRequiredPicker = _shouldUseRequiredPicker(param);
if (useRequiredPicker) {
return (
<div key={param.name} style={{ marginBottom: 8 }}>
<RequiredAttributePicker
label={getLabel(param.description, language) || param.name}
expectedType={param.type}
value={workflowParamUiValue(params, param)}
onChange={(val) => updateParam(param.name, val)}
/>
</div>
);
}
const frontendType = param.frontendType || 'text';
const Renderer = FRONTEND_TYPE_RENDERERS[frontendType] ?? FRONTEND_TYPE_RENDERERS.text;
return (
<div key={param.name} style={{ marginBottom: 8 }}>
<RequiredAttributePicker
label={getLabel(param.description, language) || param.name}
expectedType={param.type}
value={params[param.name] ?? param.default}
onChange={(val) => updateParam(param.name, val)}
<div key={`${node.id}-${param.name}`} style={{ marginBottom: 4, minWidth: 0, maxWidth: '100%' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
marginBottom: 2,
flexWrap: 'wrap',
minWidth: 0,
}}
>
{param.required && (
<span
title={t('Pflichtfeld')}
style={{ color: 'var(--danger-color, #dc3545)', fontWeight: 700, flexShrink: 0 }}
>
*
</span>
)}
{verboseSchema && param.type && (
<span
title={t('Parameter-Typ')}
style={{
fontSize: 10,
fontWeight: 600,
color: 'var(--text-secondary)',
background: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
borderRadius: 4,
padding: '1px 6px',
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{param.type}
</span>
)}
</div>
<Renderer
param={param}
value={workflowParamUiValue(params, param)}
onChange={(val: unknown) => updateParam(param.name, val)}
allParams={params}
instanceId={instanceId}
request={request}
nodeType={node.type}
onPatchParams={patchParams}
/>
</div>
);
}
const frontendType = param.frontendType || 'text';
const Renderer = FRONTEND_TYPE_RENDERERS[frontendType] ?? FRONTEND_TYPE_RENDERERS.text;
return (
<div key={`${node.id}-${param.name}`} style={{ marginBottom: 4, minWidth: 0, maxWidth: '100%' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
marginBottom: 2,
flexWrap: 'wrap',
minWidth: 0,
}}
>
{param.required && (
<span
title={t('Pflichtfeld')}
style={{ color: 'var(--danger-color, #dc3545)', fontWeight: 700, flexShrink: 0 }}
>
*
</span>
)}
{verboseSchema && param.type && (
<span
title={t('Parameter-Typ')}
style={{
fontSize: 10,
fontWeight: 600,
color: 'var(--text-secondary)',
background: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
borderRadius: 4,
padding: '1px 6px',
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{param.type}
</span>
)}
</div>
<Renderer
param={param}
value={params[param.name] ?? param.default}
onChange={(val: unknown) => updateParam(param.name, val)}
allParams={params}
instanceId={instanceId}
request={request}
nodeType={node.type}
onPatchParams={patchParams}
/>
</div>
);
})}
})
)}
</div>
);
};

View file

@ -25,6 +25,8 @@ export interface FieldRendererProps {
nodeType?: string;
/** Atomically merge several parameter keys (e.g. cron + schedule). */
onPatchParams?: (patch: Record<string, unknown>) => void;
/** Hide the prominent ``param.name`` line (e.g. Accordion header already shows it). */
hideAccordionTitle?: boolean;
}
export type FieldRendererComponent = ComponentType<FieldRendererProps>;
@ -135,25 +137,117 @@ function _normalizedSelectOptions(raw: unknown): Array<{ value: string; label: s
return out;
}
const SelectInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const SelectInput: React.FC<FieldRendererProps> = ({ param, value, onChange, hideAccordionTitle }) => {
const { t } = useLanguage();
const options = _normalizedSelectOptions(
param.frontendOptions?.options ?? param.options ?? []
);
const allowClear = !param.required;
const current = value === undefined || value === null || value === '' ? '' : String(value);
const groupId = `select-segment-${param.name.replace(/[^a-zA-Z0-9_-]/g, '_')}`;
const titleId = `${groupId}-title`;
const descId = `${groupId}-desc`;
const showNameLine = !hideAccordionTitle;
const labelledBy = showNameLine
? param.description
? `${titleId} ${descId}`
: titleId
: param.description
? descId
: undefined;
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
<select
value={typeof value === 'string' ? value : ''}
onChange={(e) => onChange(e.target.value)}
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc' }}
{showNameLine ? (
<div
id={titleId}
style={{
display: 'block',
fontSize: 12,
fontWeight: 700,
marginBottom: param.description ? 4 : 6,
color: 'var(--text-primary, #212529)',
letterSpacing: '0.01em',
}}
>
{param.name}
</div>
) : null}
{param.description ? (
<div
id={descId}
style={{
display: 'block',
fontSize: 12,
fontWeight: 400,
lineHeight: 1.35,
marginBottom: 6,
color: 'var(--text-secondary, #555)',
}}
>
{param.description}
</div>
) : null}
<div
role="radiogroup"
aria-labelledby={labelledBy ?? undefined}
aria-label={!labelledBy ? param.name : undefined}
aria-required={param.required ? true : undefined}
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 6,
alignItems: 'stretch',
}}
>
<option value=""></option>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
{options.map((opt) => {
const selected = current === opt.value;
return (
<button
key={opt.value}
type="button"
role="radio"
aria-checked={selected}
title={
allowClear && selected
? t('Erneut klicken, um die Auswahl aufzuheben')
: undefined
}
onClick={() => {
if (allowClear && selected) {
onChange(undefined);
} else {
onChange(opt.value);
}
}}
style={{
flex: '1 1 auto',
minWidth: 'min(100%, 72px)',
maxWidth: '100%',
textAlign: 'center',
padding: '6px 10px',
fontSize: 11,
lineHeight: 1.25,
borderRadius: 6,
border: selected
? '2px solid var(--primary-color, #0d6efd)'
: '1px solid var(--border-color, #ccc)',
background: selected
? 'var(--primary-soft-bg, rgba(13, 110, 253, 0.12))'
: 'var(--panel-subtle-bg, #f8f9fa)',
color: selected
? 'var(--primary-color, #0a58ca)'
: 'var(--text-primary, #212529)',
fontWeight: selected ? 600 : 400,
cursor: 'pointer',
boxShadow: selected ? 'inset 0 0 0 1px rgba(13, 110, 253, 0.15)' : 'none',
transition: 'background 0.12s ease, border-color 0.12s ease, color 0.12s ease',
}}
>
{opt.label}
</button>
);
})}
</div>
</div>
);
};