/** * Sync input.form / trigger.form fields + ClickUp "Aufgabe erstellen" refs from a selected ClickUp list. */ import type { CanvasConnection, CanvasNode } from '../../editor/FlowCanvas'; import type { FormField } from './types'; import { createRef } from './dataRef'; export type ClickUpFieldLike = Record; function buildReverseAdjacency(connections: CanvasConnection[]): Record { const rev: Record = {}; for (const c of connections) { if (!rev[c.targetId]) rev[c.targetId] = []; rev[c.targetId].push(c.sourceId); } return rev; } /** Nearest form node upstream (toward triggers) of the ClickUp node. */ export function findClosestUpstreamFormNode( targetNodeId: string, nodes: CanvasNode[], connections: CanvasConnection[] ): CanvasNode | null { const nodeById = new Map(nodes.map((n) => [n.id, n])); const rev = buildReverseAdjacency(connections); const queue: string[] = [...(rev[targetNodeId] ?? [])]; const visited = new Set(); while (queue.length > 0) { const nid = queue.shift()!; if (visited.has(nid)) continue; visited.add(nid); const n = nodeById.get(nid); if (!n) continue; if (n.type === 'input.form' || n.type === 'trigger.form') return n; for (const p of rev[nid] ?? []) { if (!visited.has(p)) queue.push(p); } } return null; } export function normalizeClickUpFieldType(raw: unknown): string { return String(raw ?? 'short_text') .trim() .toLowerCase() .replace(/-/g, '_') .replace(/\s+/g, '_'); } function linkedListIdFromRelationshipField(field: ClickUpFieldLike): string | null { const tc = (field.type_config ?? {}) as Record; const asId = (v: unknown): string | null => { if (typeof v === 'string' && v.trim()) return v.trim(); if (typeof v === 'number' && Number.isFinite(v)) return String(v); return null; }; const keys = [ 'linked_list_id', 'list_id', 'related_list_id', 'relationship_list_id', 'resource_id', ]; for (const k of keys) { const raw = tc[k]; const id = asId(raw); if (id) return id; if (raw && typeof raw === 'object' && raw !== null) { const nested = asId((raw as Record).id); if (nested) return nested; } } const rel = tc.relationship; if (rel && typeof rel === 'object' && rel !== null) { const r = rel as Record; const fromRel = asId(r.list_id ?? r.id ?? r.target_id ?? r.linked_list_id ?? r.resource_id); if (fromRel) return fromRel; } return null; } function fieldUnsupported(ft: string): boolean { return ['tasks', 'user', 'users'].includes(ft); } function mapCuToInputFormField( field: ClickUpFieldLike, connectionId: string, parentListId: string ): FormField | null { const fid = String(field.id ?? ''); if (!fid) return null; const fname = String(field.name ?? fid); const ft = normalizeClickUpFieldType(field.type); if (fieldUnsupported(ft)) return null; const name = `cf_${fid.replace(/[^a-zA-Z0-9_]/g, '_')}`; const label = fname || name; if (ft === 'list_relationship') { const lid = linkedListIdFromRelationshipField(field) ?? parentListId; return { name, label, type: 'clickup_tasks', required: false, clickupConnectionId: connectionId, clickupListId: lid, }; } if ( ft === 'drop_down' || ft === 'dropdown' || ft === 'text' || ft === 'long_text' || ft === 'short_text' || ft === 'email' || ft === 'phone' || ft === 'url' ) { return { name, label, type: 'string', required: false }; } if (ft === 'number' || ft === 'currency') { return { name, label, type: 'number', required: false }; } if (ft === 'date') { return { name, label, type: 'date', required: false }; } if (ft === 'checkbox') { return { name, label, type: 'boolean', required: false }; } return { name, label, type: 'string', required: false }; } /** trigger.form row; `clickup_status` carries options from the same list API as the ClickUp node dropdown. */ export type TriggerFormFieldRow = { name: string; label: string; type: 'text' | 'number' | 'email' | 'date' | 'boolean' | 'clickup_status'; statusOptions?: Array<{ value: string; label: string }>; }; function mapCuToTriggerFormField(field: ClickUpFieldLike, _connectionId: string, _parentListId: string): TriggerFormFieldRow | null { const fid = String(field.id ?? ''); if (!fid) return null; const fname = String(field.name ?? fid); const ft = normalizeClickUpFieldType(field.type); if (fieldUnsupported(ft)) return null; const name = `cf_${fid.replace(/[^a-zA-Z0-9_]/g, '_')}`; const label = fname || name; if (ft === 'list_relationship') { return { name, label, type: 'text' }; } if (ft === 'number' || ft === 'currency') { return { name, label, type: 'number' }; } if (ft === 'date') { return { name, label, type: 'date' }; } if (ft === 'checkbox') { return { name, label, type: 'boolean' }; } if (ft === 'email') { return { name, label, type: 'email' }; } return { name, label, type: 'text' }; } export const PAYLOAD_TITLE = 'title'; export const PAYLOAD_DESCRIPTION = 'description'; export const PAYLOAD_STATUS = 'clickup_status'; export const PAYLOAD_PRIORITY = 'clickup_priority'; export const PAYLOAD_DUE = 'clickup_due_date'; export const PAYLOAD_TIME_H = 'clickup_time_estimate_h'; /** Same ordering as ClickUp list `statuses` (GET /list/{id}). */ export function statusOptionsFromListStatuses( listStatuses: Array<{ status: string; orderindex: number }> ): Array<{ value: string; label: string }> { return [...listStatuses] .sort((a, b) => a.orderindex - b.orderindex) .map((s) => ({ value: s.status, label: s.status })); } export interface SyncFromListResult { inputFormFields: FormField[]; triggerFormFields: TriggerFormFieldRow[]; clickupPatch: Record; } /** * Build form field rows + ClickUp createTask parameter patch (refs → payload.*). */ export function buildSyncFromClickUpList(args: { formNodeId: string; listFields: ClickUpFieldLike[]; /** From GET /list/{id} → list.statuses (same source as the ClickUp node status dropdown). */ listStatuses: Array<{ status: string; orderindex: number }>; connectionId: string; teamId: string; listId: string; }): SyncFromListResult { const { formNodeId, listFields, listStatuses, connectionId, teamId, listId } = args; const ref = (key: string) => createRef(formNodeId, ['payload', key]); const statusOpts = statusOptionsFromListStatuses(listStatuses); const standardInput: FormField[] = [ { name: PAYLOAD_TITLE, label: 'Titel', type: 'string', required: true }, { name: PAYLOAD_DESCRIPTION, label: 'Beschreibung', type: 'string', required: false }, ...(statusOpts.length > 0 ? [ { name: PAYLOAD_STATUS, label: 'Status', type: 'clickup_status', required: false, clickupStatusOptions: statusOpts, } as FormField, ] : []), { name: PAYLOAD_PRIORITY, label: 'Priorität (1–4)', type: 'number', required: false }, { name: PAYLOAD_DUE, label: 'Fälligkeit', type: 'date', required: false }, { name: PAYLOAD_TIME_H, label: 'Zeitschätzung (Stunden)', type: 'number', required: false }, ]; const statusTriggerRow: TriggerFormFieldRow | null = statusOpts.length > 0 ? { name: PAYLOAD_STATUS, label: 'Status', type: 'clickup_status', statusOptions: statusOpts, } : null; const standardTrigger: TriggerFormFieldRow[] = [ { name: PAYLOAD_TITLE, label: 'Titel', type: 'text' }, { name: PAYLOAD_DESCRIPTION, label: 'Beschreibung', type: 'text' }, ...(statusTriggerRow ? [statusTriggerRow] : []), { name: PAYLOAD_PRIORITY, label: 'Priorität (1–4)', type: 'number' }, { name: PAYLOAD_DUE, label: 'Fälligkeit', type: 'date' }, { name: PAYLOAD_TIME_H, label: 'Zeitschätzung (Stunden)', type: 'number' }, ]; if (statusOpts.length > 0) { standardTrigger.splice(2, 0, { name: PAYLOAD_STATUS, label: 'Status', type: 'clickup_status', statusOptions: statusOpts, }); } const customInput: FormField[] = []; const customTrigger: TriggerFormFieldRow[] = []; const customRefs: Record = {}; for (const f of listFields) { if (!f || typeof f !== 'object') continue; const inf = mapCuToInputFormField(f as ClickUpFieldLike, connectionId, listId); const tr = mapCuToTriggerFormField(f as ClickUpFieldLike, connectionId, listId); if (inf) customInput.push(inf); if (tr) customTrigger.push(tr); const fid = String((f as ClickUpFieldLike).id ?? ''); const payloadKey = inf?.name; if (fid && payloadKey) { customRefs[fid] = createRef(formNodeId, ['payload', payloadKey]); } } const inputFormFields = [...standardInput, ...customInput]; const triggerFormFields = [...standardTrigger, ...customTrigger]; const clickupPatch: Record = { connectionId, teamId, listId, path: `/team/${teamId}/list/${listId}`, name: ref(PAYLOAD_TITLE), description: ref(PAYLOAD_DESCRIPTION), taskPriority: ref(PAYLOAD_PRIORITY), taskDueDateMs: ref(PAYLOAD_DUE), taskTimeEstimateHours: ref(PAYLOAD_TIME_H), }; if (statusOpts.length > 0) { clickupPatch.taskStatus = ref(PAYLOAD_STATUS); } if (Object.keys(customRefs).length) { clickupPatch.customFieldValues = customRefs; } return { inputFormFields, triggerFormFields, clickupPatch }; }