/** * Inline dropdown to select a data source (node + path) - no popup. * Form nodes (trigger.form / input.form): only payload. 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; const ct = p.clickupTask; if (ct && typeof ct === 'object' && !Array.isArray(ct)) { const o = ct as Record; 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)) { result.push(...buildPickablePaths(v, [...basePath, k])); } return result; } return [{ path: [...basePath], pathLabel }]; } /** Nur Formular-Felder: ein Eintrag pro Feld unter payload. — kein rekursives Durchwandern. */ function buildFormSchemaPayloadPaths(params: Record): 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).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).currentItem; if (ci && typeof ci === 'object' && !Array.isArray(ci)) { for (const [k, v] of Object.entries(ci as Record)) { 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).responseData; if (rd && typeof rd === 'object' && !Array.isArray(rd)) { for (const k of Object.keys(rd as Record)) { 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 } | 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 = ({ 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 ( ); }; 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 = ({ 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 ( ); }; /** Inferred field type for operator selection and value input */ export type FieldType = 'string' | 'number' | 'boolean' | 'date' | 'email' | 'file' | 'unknown'; function getFormFieldType( node: { parameters?: Record; 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).name === fieldName); if (!field || typeof field !== 'object') return null; const rawFieldType = String((field as Record).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; type?: string }>, nodeOutputsPreview: Record ): 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)[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'; }