frontend_nyla/src/components/FlowEditor/nodes/shared/clickupFormSync.ts
2026-04-07 00:49:12 +02:00

294 lines
9.5 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.

/**
* 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<string, unknown>;
function buildReverseAdjacency(connections: CanvasConnection[]): Record<string, string[]> {
const rev: Record<string, string[]> = {};
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<string>();
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<string, unknown>;
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<string, unknown>).id);
if (nested) return nested;
}
}
const rel = tc.relationship;
if (rel && typeof rel === 'object' && rel !== null) {
const r = rel as Record<string, unknown>;
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<string, unknown>;
}
/**
* 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 (14)', 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 (14)', 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<string, unknown> = {};
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<string, unknown> = {
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 };
}