feat: extract content node angepasst für mehr optionen
This commit is contained in:
parent
bef2aa8b83
commit
919ad061e1
2 changed files with 429 additions and 83 deletions
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue