frontend_nyla/src/components/FlowEditor/nodes/shared/RefSourceSelect.tsx
2026-04-29 21:27:15 +02:00

450 lines
16 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.

/**
* Inline dropdown to select a data source (node + path) - no popup.
* Form nodes (trigger.form / input.form): only payload.<fieldName> paths (no duplicate tree).
*/
import React from 'react';
import { createRef, isRef, isValue, createValue, type DataRef } from './dataRef';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { useLanguage } from '../../../../providers/language/LanguageContext';
/** How to build path options for StatischKontextSelect / RefSourceSelect. */
export type PathPickMode = 'default' | 'clickup_task_id' | 'exclude_forms';
/** Only task IDs from ClickUp nodes — single path (taskId === clickupTask.id at runtime). */
function buildClickUpTaskIdPaths(): Array<{ path: (string | number)[]; pathLabel: string }> {
return [{ path: ['taskId'], pathLabel: 'Aufgaben-ID' }];
}
/** Curated paths for clickup.* outputs — avoids huge documentData / payload trees. */
function buildClickUpOutputPaths(preview: unknown): Array<{ path: (string | number)[]; pathLabel: string }> {
const paths: Array<{ path: (string | number)[]; pathLabel: string }> = [
{ path: ['taskId'], pathLabel: 'Aufgaben-ID' },
{ path: ['clickupTask', 'name'], pathLabel: 'clickupTask.name' },
{ path: ['success'], pathLabel: 'success' },
{ path: ['error'], pathLabel: 'error' },
{ path: ['documents', 0, 'documentName'], pathLabel: 'documents[0].documentName' },
];
if (preview && typeof preview === 'object') {
const p = preview as Record<string, unknown>;
const ct = p.clickupTask;
if (ct && typeof ct === 'object' && !Array.isArray(ct)) {
const o = ct as Record<string, unknown>;
for (const k of Object.keys(o)) {
if (k === 'id' || k === 'name') continue;
const v = o[k];
if (v != null && typeof v !== 'object') {
paths.push({ path: ['clickupTask', k], pathLabel: `clickupTask.${k}` });
}
if (k === 'status' && v && typeof v === 'object') {
paths.push({
path: ['clickupTask', 'status', 'status'],
pathLabel: 'clickupTask.status.status',
});
}
}
}
}
return paths;
}
function buildPickablePaths(
obj: unknown,
basePath: (string | number)[] = []
): Array<{ path: (string | number)[]; pathLabel: string }> {
const pathLabel = basePath.length ? basePath.map(String).join('.') : '';
if (obj == null || typeof obj === 'string' || typeof obj === 'number' || typeof obj === 'boolean') {
return [{ path: [...basePath], pathLabel }];
}
if (Array.isArray(obj)) {
const result: Array<{ path: (string | number)[]; pathLabel: string }> = [{ path: [...basePath], pathLabel }];
for (let i = 0; i < Math.min(obj.length, 10); i++) {
result.push(...buildPickablePaths(obj[i], [...basePath, i]));
}
return result;
}
if (typeof obj === 'object') {
const result: Array<{ path: (string | number)[]; pathLabel: string }> = [{ path: [...basePath], pathLabel }];
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
result.push(...buildPickablePaths(v, [...basePath, k]));
}
return result;
}
return [{ path: [...basePath], pathLabel }];
}
/** Nur Formular-Felder: ein Eintrag pro Feld unter payload.<name> — kein rekursives Durchwandern. */
function buildFormSchemaPayloadPaths(params: Record<string, unknown>): Array<{
path: (string | number)[];
pathLabel: string;
}> {
const raw = params.formFields ?? params.fields;
if (!Array.isArray(raw)) return [];
const out: Array<{ path: (string | number)[]; pathLabel: string }> = [];
for (let i = 0; i < raw.length; i++) {
const row = raw[i];
if (!row || typeof row !== 'object') continue;
const name = String((row as Record<string, unknown>).name ?? `field${i + 1}`).trim();
if (!name) continue;
out.push({ path: ['payload', name], pathLabel: `payload.${name}` });
}
return out;
}
function buildLoopCurrentItemPaths(preview: unknown): Array<{ path: (string | number)[]; pathLabel: string }> {
const paths: Array<{ path: (string | number)[]; pathLabel: string }> = [
{ path: ['currentItem'], pathLabel: 'currentItem' },
{ path: ['currentIndex'], pathLabel: 'currentIndex' },
{ path: ['count'], pathLabel: 'count' },
];
if (preview && typeof preview === 'object') {
const ci = (preview as Record<string, unknown>).currentItem;
if (ci && typeof ci === 'object' && !Array.isArray(ci)) {
for (const [k, v] of Object.entries(ci as Record<string, unknown>)) {
paths.push(...buildPickablePaths(v, ['currentItem', k]));
}
}
}
return paths;
}
function buildAiPromptPaths(preview: unknown): Array<{ path: (string | number)[]; pathLabel: string }> {
const paths = buildPickablePaths(preview);
if (preview && typeof preview === 'object') {
const rd = (preview as Record<string, unknown>).responseData;
if (rd && typeof rd === 'object' && !Array.isArray(rd)) {
for (const k of Object.keys(rd as Record<string, unknown>)) {
const p = { path: ['responseData', k], pathLabel: `responseData.${k}` };
if (!paths.some((x) => x.pathLabel === p.pathLabel)) paths.push(p);
}
}
}
return paths;
}
export function pickPathsForNode(
node: { type?: string; parameters?: Record<string, unknown> } | undefined,
preview: unknown,
mode: PathPickMode = 'default'
): Array<{ path: (string | number)[]; pathLabel: string }> {
if (!node) return buildPickablePaths(preview);
const nt = node.type ?? '';
if (mode === 'clickup_task_id') {
if (nt.startsWith('clickup.')) {
return buildClickUpTaskIdPaths();
}
return [];
}
if (nt === 'trigger.form' || nt === 'input.form') {
return buildFormSchemaPayloadPaths(node.parameters ?? {});
}
if (node.type === 'input.upload') {
return buildPickablePathsForUpload();
}
if (nt.startsWith('clickup.')) {
return buildClickUpOutputPaths(preview);
}
if (nt === 'flow.loop') {
return buildLoopCurrentItemPaths(preview);
}
if (nt === 'ai.prompt') {
return buildAiPromptPaths(preview);
}
return buildPickablePaths(preview);
}
/** Für input.upload: nur relevante Pfade für If/Else MIME-Type, Dateiname, Datei vorhanden. */
function buildPickablePathsForUpload(): Array<{ path: (string | number)[]; pathLabel: string }> {
return [
{ path: [], pathLabel: '' },
{ path: ['file'], pathLabel: 'file' },
{ path: ['file', 'mimeType'], pathLabel: 'file.mimeType' },
{ path: ['file', 'fileName'], pathLabel: 'file.fileName' },
{ path: ['files'], pathLabel: 'files' },
{ path: ['fileIds'], pathLabel: 'fileIds' },
];
}
export function refToOptionValue(ref: DataRef): string {
return JSON.stringify(ref);
}
function _pathLabelForDisplay(pathLabel: string, translate: (key: string) => string): string {
if (pathLabel === 'Aufgaben-ID') return translate('Aufgaben-ID');
return pathLabel;
}
export function optionValueToRef(s: string): DataRef | null {
try {
const o = JSON.parse(s) as unknown;
if (o && typeof o === 'object' && (o as DataRef).type === 'ref' && typeof (o as DataRef).nodeId === 'string') {
return o as DataRef;
}
} catch {
/* ignore */
}
return null;
}
/** Option value for „Statisch (manuell)“ in StatischKontextSelect. */
export const STATIC_SOURCE_VALUE = '__static__';
function parseHybridLocal(value: unknown): { ref: DataRef | null; staticStr: string } {
if (isRef(value)) return { ref: value, staticStr: '' };
if (isValue(value)) {
const v = value.value;
if (v === null || v === undefined) return { ref: null, staticStr: '' };
return { ref: null, staticStr: String(v) };
}
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return { ref: null, staticStr: String(value) };
}
return { ref: null, staticStr: '' };
}
/** Aktueller Wert des Quellen-Dropdowns: '' | STATIC_SOURCE_VALUE | ref-JSON. */
export function getStaticContextSelectValue(value: unknown): string {
if (isRef(value)) return refToOptionValue(value);
if (value === undefined || value === null) return '';
return STATIC_SOURCE_VALUE;
}
/** Statische Eingabe (Text, Checkbox, ClickUp-Option, …) nur bei „Statisch“ oder ohne vorgelagerte Nodes. */
export function shouldShowStaticControl(value: unknown, hasSources: boolean): boolean {
if (!hasSources) return true;
if (isRef(value)) return false;
return getStaticContextSelectValue(value) === STATIC_SOURCE_VALUE;
}
interface StatischKontextSelectProps {
value: unknown;
onChange: (v: unknown) => void;
placeholder?: string;
/** Label für die manuelle Option (Default: Statisch). */
staticLabel?: string;
/** default: full tree; clickup_task_id: only taskId from ClickUp nodes; exclude_forms: skip form nodes. */
pathPickMode?: PathPickMode;
}
/**
* Ein Dropdown: zuerst „Quelle wählen“, dann „Statisch“, dann Kontextpfade.
* Bei Kontext-Ref kein paralleles Textfeld (nur in HybridStaticRefField / ClickUp bei shouldShowStaticControl).
*/
export const StatischKontextSelect: React.FC<StatischKontextSelectProps> = ({
value,
onChange,
placeholder,
staticLabel,
pathPickMode = 'default',
}) => {
const { t } = useLanguage();
const dataFlow = useAutomation2DataFlow();
if (!dataFlow) return null;
const sourceIds = dataFlow.getAvailableSourceIds();
const options: Array<{ ref: DataRef; label: string }> = [];
for (const nodeId of sourceIds) {
const node = dataFlow.nodes.find((n) => n.id === nodeId);
if (node?.type === 'trigger.manual') continue;
if (
pathPickMode === 'exclude_forms' &&
(node?.type === 'input.form' || node?.type === 'trigger.form')
) {
continue;
}
const preview = dataFlow.nodeOutputsPreview[nodeId];
const nodeLabel = node ? dataFlow.getNodeLabel(node) : nodeId;
const paths = pickPathsForNode(node, preview, pathPickMode);
for (const p of paths) {
const pathLabelUi = _pathLabelForDisplay(p.pathLabel, t);
const displayLabel = pathLabelUi ? `${nodeLabel}${pathLabelUi}` : nodeLabel;
options.push({
ref: createRef(nodeId, p.path),
label: displayLabel,
});
}
}
const currentSelect = isRef(value)
? refToOptionValue(value)
: value === undefined || value === null
? ''
: STATIC_SOURCE_VALUE;
return (
<select
value={currentSelect}
onChange={(e) => {
const v = e.target.value;
if (v === '') {
onChange(createValue(''));
return;
}
if (v === STATIC_SOURCE_VALUE) {
const { staticStr } = parseHybridLocal(value);
onChange(createValue(isRef(value) ? '' : staticStr));
return;
}
const ref = optionValueToRef(v);
if (ref) onChange(ref);
}}
>
<option value="">{placeholder ?? t('— Quelle wählen —')}</option>
<option value={STATIC_SOURCE_VALUE}>{staticLabel ?? t('Statisch')}</option>
{options.map((o) => (
<option key={refToOptionValue(o.ref)} value={refToOptionValue(o.ref)}>
{o.label}
</option>
))}
</select>
);
};
interface RefSourceSelectProps {
value: DataRef | null;
onChange: (ref: DataRef | null) => void;
placeholder?: string;
pathPickMode?: PathPickMode;
}
/** Nur Kontext-Referenzen (ohne Statisch) — für If/Else, Switch, DynamicValueField. */
export const RefSourceSelect: React.FC<RefSourceSelectProps> = ({
value,
onChange,
placeholder,
pathPickMode = 'default',
}) => {
const { t } = useLanguage();
const dataFlow = useAutomation2DataFlow();
if (!dataFlow) return null;
const sourceIds = dataFlow.getAvailableSourceIds();
const options: Array<{ ref: DataRef; label: string }> = [];
for (const nodeId of sourceIds) {
const node = dataFlow.nodes.find((n) => n.id === nodeId);
if (node?.type === 'trigger.manual') continue;
if (
pathPickMode === 'exclude_forms' &&
(node?.type === 'input.form' || node?.type === 'trigger.form')
) {
continue;
}
const preview = dataFlow.nodeOutputsPreview[nodeId];
const nodeLabel = node ? dataFlow.getNodeLabel(node) : nodeId;
const paths = pickPathsForNode(node, preview, pathPickMode);
for (const p of paths) {
const pathLabelUi = _pathLabelForDisplay(p.pathLabel, t);
const displayLabel = pathLabelUi ? `${nodeLabel}${pathLabelUi}` : nodeLabel;
options.push({
ref: createRef(nodeId, p.path),
label: displayLabel,
});
}
}
const currentValue = value ? refToOptionValue(value) : '';
return (
<select
value={currentValue}
onChange={(e) => {
const v = e.target.value;
if (!v) {
onChange(null);
return;
}
const ref = optionValueToRef(v);
if (ref) onChange(ref);
}}
>
<option value="">{placeholder ?? t('Datenquelle wählen…')}</option>
{options.map((o) => (
<option key={refToOptionValue(o.ref)} value={refToOptionValue(o.ref)}>
{o.label}
</option>
))}
</select>
);
};
/** Inferred field type for operator selection and value input */
export type FieldType = 'string' | 'number' | 'boolean' | 'date' | 'email' | 'file' | 'unknown';
function getFormFieldType(
node: { parameters?: Record<string, unknown>; type?: string },
path: (string | number)[]
): FieldType | null {
const params = node.parameters ?? {};
const raw = params.formFields ?? params.fields;
if (!Array.isArray(raw)) return null;
const isFormPayload =
(node.type === 'trigger.form' || node.type === 'input.form') && path[0] === 'payload';
const fieldName =
isFormPayload && path.length >= 2
? String(path[1])
: path.length >= 1
? String(path[0])
: null;
if (!fieldName) return null;
const field = raw.find((f: unknown) => f && typeof f === 'object' && (f as Record<string, unknown>).name === fieldName);
if (!field || typeof field !== 'object') return null;
const rawFieldType = String((field as Record<string, unknown>).type ?? 'text').toLowerCase();
if (rawFieldType === 'number') return 'number';
if (rawFieldType === 'email') return 'email';
if (rawFieldType === 'date' || rawFieldType === 'datetime') return 'date';
if (rawFieldType === 'boolean' || rawFieldType === 'checkbox') return 'boolean';
return 'string';
}
function getNodeOutputFieldType(
node: { type?: string },
path: (string | number)[]
): FieldType | null {
if (node.type === 'input.upload') {
if (path.length === 0 || (path.length === 1 && path[0] === 'file')) return 'file';
if (path[0] === 'file' && path[1] === 'mimeType') return 'string';
if (path[0] === 'file' && path[1] === 'fileName') return 'string';
if (path.length === 1 && (path[0] === 'files' || path[0] === 'fileIds')) return 'file';
}
if ((node.type?.startsWith('sharepoint.') || node.type?.startsWith('email.')) && path.includes('file')) {
const last = path[path.length - 1];
if (last === 'mimeType' || last === 'fileName') return 'string';
return 'file';
}
return null;
}
/** Infer field type from ref: form schema, node output shape, or preview value. */
export function getFieldType(
ref: DataRef | null,
nodes: Array<{ id: string; parameters?: Record<string, unknown>; type?: string }>,
nodeOutputsPreview: Record<string, unknown>
): FieldType {
if (!ref) return 'unknown';
const node = nodes.find((n) => n.id === ref.nodeId);
if (node) {
const fromForm = getFormFieldType(node, ref.path);
if (fromForm) return fromForm;
const fromNode = getNodeOutputFieldType(node, ref.path);
if (fromNode) return fromNode;
}
const root = nodeOutputsPreview[ref.nodeId];
if (root === undefined) return 'unknown';
let current: unknown = root;
for (const seg of ref.path) {
if (current == null) return 'unknown';
const key = typeof seg === 'number' ? String(seg) : seg;
if (Array.isArray(current) && /^\d+$/.test(key)) {
current = current[parseInt(key, 10)];
} else if (typeof current === 'object' && key in current) {
current = (current as Record<string, unknown>)[key];
} else return 'unknown';
}
if (typeof current === 'string') return 'string';
if (typeof current === 'number') return 'number';
if (typeof current === 'boolean') return 'boolean';
if (current && typeof current === 'object' && 'url' in (current as object)) return 'file';
return 'unknown';
}