282 lines
9.6 KiB
TypeScript
282 lines
9.6 KiB
TypeScript
/**
|
||
* 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>
|
||
);
|
||
};
|