294 lines
9.5 KiB
TypeScript
294 lines
9.5 KiB
TypeScript
/**
|
||
* 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 (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<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 };
|
||
}
|