ui-nyla/src/components/FlowEditor/nodes/shared/RequiredAttributePicker.tsx
2026-04-26 08:31:31 +02:00

282 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* RequiredAttributePicker — Phase-4 Schicht-4 binding affordance for
* required parameters of a Schicht-3 Adapter (Editor-Node).
*
* 0/1/N logic, applied on the set of typed source candidates:
* - 0 candidates → red pill: "Keine typkompatible Quelle vorhanden"
* (user must add an upstream node first)
* - 1 candidate → auto-bound chip with a "Andere wählen…" override button
* (still shown explicitly so the user sees what was chosen)
* - N candidates → "Quelle wählen…" button that opens the DataPicker
* pre-filtered to the expected type
*
* The picker also surfaces a "Iterieren als Loop" hint when the expected type
* is `X` and an upstream candidate is `List[X]` — see paramValidation.ts.
*/
import React, { useMemo, useState } from 'react';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { DataPicker } from './DataPicker';
import { createRef, formatRefLabel, isRef, type DataRef, type SystemVarRef } from './dataRef';
import { findSourceCandidates, strictlyCompatible, type SourceCandidate } from './paramValidation';
import styles from '../../editor/Automation2FlowEditor.module.css';
import { useLanguage } from '../../../../providers/language/LanguageContext';
export interface RequiredAttributePickerProps {
/** Display label for the parameter (already localized). */
label: string;
/** Type expected by the bound action argument (e.g. "DocumentList", "str"). */
expectedType?: string;
/** Current bound value (DataRef, SystemVarRef, or unset). */
value: unknown;
/** Persist a new binding (or `null` to clear). */
onChange: (next: DataRef | SystemVarRef | null) => void;
/** Optional description shown beneath the picker. */
description?: React.ReactNode;
}
export const RequiredAttributePicker: React.FC<RequiredAttributePickerProps> = ({
label,
expectedType,
value,
onChange,
description,
}) => {
const { t } = useLanguage();
const ctx = useAutomation2DataFlow();
const [pickerOpen, setPickerOpen] = useState(false);
const consumerNodeId = ctx?.currentNodeId ?? '';
const nodes = ctx?.nodes ?? [];
const connections = ctx?.connections ?? [];
const nodeTypes = ctx?.nodeTypes ?? [];
const catalog = ctx?.portTypeCatalog ?? {};
const allCandidates: SourceCandidate[] = useMemo(() => {
if (!consumerNodeId) return [];
return findSourceCandidates({
consumerNodeId,
expectedType,
nodes,
connections: connections.map((c) => ({
id: c.id,
sourceId: c.sourceId,
sourceHandle: c.sourceHandle,
targetId: c.targetId,
targetHandle: c.targetHandle,
})),
nodeTypes,
portTypeCatalog: catalog,
});
}, [consumerNodeId, expectedType, nodes, connections, nodeTypes, catalog]);
const compatibleCandidates = useMemo(() => strictlyCompatible(allCandidates), [allCandidates]);
const isBoundRef = isRef(value);
const boundLabel = isBoundRef ? formatRefLabel(value as DataRef, nodes) : null;
// 0/1/N
const candidateCount = compatibleCandidates.length;
const single = candidateCount === 1 ? compatibleCandidates[0] : null;
const handleAutoBind = () => {
if (!single) return;
const ref = createRef(single.nodeId, single.iterable && expectedType ? [...single.path, '*'] : single.path, expectedType);
onChange(ref);
};
const handlePicked = (picked: DataRef | SystemVarRef) => {
onChange(picked);
};
return (
<div
className={styles.requiredAttributePicker}
style={{
display: 'flex',
flexDirection: 'column',
gap: 4,
minWidth: 0,
maxWidth: '100%',
}}
>
{/* Header: label always takes the full row (flex-basis 100 %), badge
wraps below — prevents long type names like List[ActionDocument]
from escaping the panel frame on the right. */}
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6, flexWrap: 'wrap' }}>
<label
style={{
fontSize: 12,
fontWeight: 600,
flex: '1 1 100%',
minWidth: 0,
overflowWrap: 'anywhere',
wordBreak: 'break-word',
}}
>
{label}
<span style={{ color: 'var(--danger-color, #dc3545)', marginLeft: 2 }}>*</span>
</label>
{expectedType && (
<span
title={t('Erwarteter Typ')}
style={{
fontSize: 10,
fontFamily: 'monospace',
color: 'var(--text-secondary, #555)',
background: 'var(--bg-secondary, #eee)',
border: '1px solid var(--border-color, #e0e0e0)',
borderRadius: 4,
padding: '1px 6px',
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{expectedType}
</span>
)}
</div>
{isBoundRef ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap', minWidth: 0 }}>
<span
title={typeof boundLabel === 'string' ? boundLabel : undefined}
style={{
padding: '2px 8px',
borderRadius: 12,
background: 'rgba(40,167,69,0.15)',
color: 'var(--success-color, #28a745)',
fontSize: 12,
fontWeight: 500,
maxWidth: '100%',
overflowWrap: 'anywhere',
wordBreak: 'break-word',
lineHeight: 1.4,
}}
>
{boundLabel}
</span>
<button
type="button"
className={styles.retryButton}
onClick={() => setPickerOpen(true)}
style={{ fontSize: 11, padding: '2px 8px', flexShrink: 0 }}
>
{t('Andere wählen…')}
</button>
<button
type="button"
className={styles.retryButton}
onClick={() => onChange(null)}
style={{ fontSize: 11, padding: '2px 8px', flexShrink: 0 }}
title={t('Bindung entfernen')}
>
×
</button>
</div>
) : candidateCount === 0 ? (
<div
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 6,
padding: '4px 8px',
background: 'rgba(220,53,69,0.12)',
color: 'var(--danger-color, #dc3545)',
borderRadius: 6,
fontSize: 12,
minWidth: 0,
overflowWrap: 'anywhere',
wordBreak: 'break-word',
}}
>
<span aria-hidden="true" style={{ flexShrink: 0 }}></span>
<span style={{ minWidth: 0 }}>
{t('Keine typkompatible Quelle vorhanden — füge zuerst einen Knoten ein, der ')}
<code style={{ fontFamily: 'monospace', overflowWrap: 'anywhere' }}>{expectedType ?? '?'}</code>
{t(' liefert.')}
</span>
</div>
) : single ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap', minWidth: 0 }}>
<button
type="button"
className={styles.retryButton}
onClick={handleAutoBind}
style={{
fontSize: 11,
padding: '3px 10px',
maxWidth: '100%',
whiteSpace: 'normal',
textAlign: 'left',
overflowWrap: 'anywhere',
wordBreak: 'break-word',
lineHeight: 1.4,
}}
title={t('Einzige passende Quelle übernehmen')}
>
{t('Vorschlag übernehmen:')}{' '}
<strong>
{nodes.find((n) => n.id === single.nodeId)?.title ?? single.nodeId}
{single.path.length > 0 ? ' → ' + single.path.map(String).join(' → ') : ''}
{single.iterable ? ' [' + t('iterieren') + ']' : ''}
</strong>
</button>
<button
type="button"
className={styles.retryButton}
onClick={() => setPickerOpen(true)}
style={{ fontSize: 11, padding: '3px 10px', flexShrink: 0 }}
>
{t('Andere…')}
</button>
</div>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap', minWidth: 0 }}>
<button
type="button"
className={styles.retryButton}
onClick={() => setPickerOpen(true)}
style={{ fontSize: 11, padding: '3px 10px', maxWidth: '100%' }}
>
{t('Quelle wählen…')} <span style={{ opacity: 0.6 }}>({candidateCount})</span>
</button>
</div>
)}
{description && (
<div
style={{
fontSize: 11,
color: 'var(--text-tertiary, #888)',
overflowWrap: 'anywhere',
wordBreak: 'break-word',
}}
>
{description}
</div>
)}
{pickerOpen && (
<DataPicker
open={pickerOpen}
onClose={() => setPickerOpen(false)}
onPick={(picked) => {
handlePicked(picked);
setPickerOpen(false);
}}
availableSourceIds={ctx?.getAvailableSourceIds() ?? []}
nodes={nodes}
nodeOutputsPreview={ctx?.nodeOutputsPreview ?? {}}
getNodeLabel={(n) =>
ctx?.getNodeLabel(n as { id: string; title?: string; label?: string; type?: string }) ?? n.id
}
expectedParamType={expectedType}
/>
)}
</div>
);
};