frontend_nyla/src/components/FlowEditor/nodes/configs/ClickUpNodeConfig.tsx
2026-04-07 00:49:12 +02:00

2221 lines
72 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.

/**
* ClickUp node config — connection, browse, search, create task with list fields.
*/
import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import type { NodeConfigRendererProps } from './types';
import {
fetchConnections,
fetchBrowse,
fetchClickupList,
fetchClickupTask,
fetchClickupTeam,
fetchClickupListFields,
loadClickupListTasksForDropdown,
type UserConnection,
type BrowseEntry,
type ApiRequestFunction,
} from '../../../../api/workflowApi';
import { SharepointBrowseTree } from '../../../FolderTree/SharepointBrowseTree';
import { HybridStaticRefField } from '../shared/HybridStaticRefField';
import {
StatischKontextSelect,
shouldShowStaticControl,
} from '../shared/RefSourceSelect';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { isRef, isValue, createValue, resolvePreview } from '../shared/dataRef';
import {
buildSyncFromClickUpList,
findClosestUpstreamFormNode,
type ClickUpFieldLike,
} from '../shared/clickupFormSync';
import styles from '../../editor/Automation2FlowEditor.module.css';
/** ClickUp browse: list folder paths end with /list/{id} */
const LIST_SEGMENT_RE = /\/list\/([^/]+)$/;
/** List id from stored path (e.g. /team/…/list/{id}/…) when listId param is empty. */
function parseListIdFromPath(path: unknown): string | null {
if (typeof path !== 'string' || !path.trim()) return null;
const m = path.match(/\/list\/([^/?#]+)/);
return m && m[1] ? m[1].trim() : null;
}
function teamIdFromBrowseEntry(e: BrowseEntry): string | null {
const m = e.path.match(/^\/team\/([^/]+)$/);
if (m) return m[1];
const raw = e.metadata?.id;
if (typeof raw === 'string' && raw) return raw;
if (typeof raw === 'number') return String(raw);
return null;
}
/** Recursively collect list “tables” under /team/{teamId} (spaces, folders, folderless lists). */
async function collectListsUnderTeam(
request: ApiRequestFunction,
instanceId: string,
connectionId: string,
teamPath: string
): Promise<Array<{ id: string; name: string }>> {
const acc: Array<{ id: string; name: string }> = [];
const seen = new Set<string>();
const visit = async (path: string) => {
const { items } = await fetchBrowse(request, instanceId, connectionId, 'clickup', path);
for (const e of items) {
if (!e.isFolder) continue;
if (LIST_SEGMENT_RE.test(e.path)) {
const mid = e.path.match(LIST_SEGMENT_RE);
if (mid && !seen.has(mid[1])) {
seen.add(mid[1]);
acc.push({ id: mid[1], name: e.name });
}
} else {
await visit(e.path);
}
}
};
await visit(teamPath);
acc.sort((a, b) => a.name.localeCompare(b.name, 'de'));
return acc;
}
const browseDetailsStyle: React.CSSProperties = {
marginTop: 12,
border: '1px solid var(--border-color, #e0e0e0)',
borderRadius: 6,
background: 'var(--bg-secondary, #f8f9fa)',
overflow: 'hidden',
};
const browseSummaryStyle: React.CSSProperties = {
padding: '0.5rem 0.75rem',
cursor: 'pointer',
fontWeight: 500,
fontSize: '0.875rem',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
userSelect: 'none',
};
const browseBodyStyle: React.CSSProperties = {
padding: '0.5rem 0.75rem',
borderTop: '1px solid var(--border-color, #e0e0e0)',
maxHeight: 280,
overflowY: 'auto',
};
function browseTitle(nodeType: string): string {
switch (nodeType) {
case 'clickup.listTasks':
return 'Liste wählen (Ordner bis /team/…/list/…)';
case 'clickup.getTask':
case 'clickup.updateTask':
case 'clickup.uploadAttachment':
return 'Aufgabe wählen (Pfad …/task/…)';
default:
return 'ClickUp durchsuchen';
}
}
function isListPicker(nodeType: string): boolean {
return nodeType === 'clickup.listTasks';
}
function isTaskPicker(nodeType: string): boolean {
return (
nodeType === 'clickup.getTask' ||
nodeType === 'clickup.updateTask' ||
nodeType === 'clickup.uploadAttachment'
);
}
const needsTeamList = (nt: string) =>
nt === 'clickup.searchTasks' || nt === 'clickup.createTask' || nt === 'clickup.updateTask';
/** Status names from GET /list/{id} (same as ClickUp table columns). */
function parseListStatuses(data: Record<string, unknown>): Array<{ status: string; orderindex: number }> {
if (data && typeof data === 'object' && 'error' in data && (data as { error?: unknown }).error) {
return [];
}
const nested = data.list as Record<string, unknown> | undefined;
const list = nested && typeof nested === 'object' ? nested : data;
let raw = list?.statuses;
if (!Array.isArray(raw) && Array.isArray(data.statuses)) {
raw = data.statuses;
}
if (!Array.isArray(raw)) return [];
const out: Array<{ status: string; orderindex: number }> = [];
raw.forEach((s, idx) => {
if (typeof s === 'string' && s.trim()) {
out.push({ status: s.trim(), orderindex: idx });
return;
}
if (!s || typeof s !== 'object') return;
const ob = s as Record<string, unknown>;
let st: string | undefined;
if (typeof ob.status === 'string' && ob.status) {
st = ob.status;
} else if (ob.status && typeof ob.status === 'object' && ob.status !== null) {
const inner = (ob.status as Record<string, unknown>).status;
if (typeof inner === 'string' && inner) st = inner;
}
if (!st && typeof ob.name === 'string' && ob.name) st = ob.name;
const oi = ob.orderindex;
if (st) {
const o =
typeof oi === 'number'
? oi
: typeof oi === 'string'
? parseInt(oi, 10)
: 0;
out.push({ status: st, orderindex: Number.isFinite(o) ? o : 0 });
}
});
out.sort((a, b) => a.orderindex - b.orderindex);
return out;
}
/** List id from GET /task/{id} when only task id is known (ref / static). */
function listIdFromClickUpTaskApi(data: Record<string, unknown>): string | null {
const root =
data.task && typeof data.task === 'object' && data.task !== null
? (data.task as Record<string, unknown>)
: data;
const list = root.list;
if (list && typeof list === 'object' && list !== null) {
const id = (list as Record<string, unknown>).id;
if (id != null && String(id).trim()) return String(id);
}
if (root.list_id != null && String(root.list_id).trim()) return String(root.list_id);
return null;
}
/** Reduces bogus GET /task calls (placeholders, garbage). */
function isPlausibleClickUpTaskId(tid: string): boolean {
const t = tid.trim();
if (t.length < 4 || t.length > 64) return false;
if (t.includes('{{') || t.includes('}}')) return false;
return /^[\w-]+$/.test(t);
}
/** Members from GET /team/{id} for assignee multi-select. */
function parseTeamMembers(data: Record<string, unknown>): Array<{ id: string; username: string }> {
const team = (data.team as Record<string, unknown> | undefined) ?? data;
const members = team?.members;
if (!Array.isArray(members)) return [];
const out: Array<{ id: string; username: string }> = [];
for (const m of members) {
if (!m || typeof m !== 'object') continue;
const ob = m as Record<string, unknown>;
const user = ob.user;
const u =
user && typeof user === 'object'
? (user as Record<string, unknown>)
: ob;
const id = u.id != null ? String(u.id) : '';
if (!id) continue;
const username = String(u.username ?? u.email ?? id);
out.push({ id, username });
}
out.sort((a, b) => a.username.localeCompare(b.username, 'de'));
return out;
}
type ClickUpField = Record<string, unknown>;
/** ClickUp API uses option UUID/string id as value, not orderindex (see custom fields docs). */
function normalizeFieldType(raw: unknown): string {
return String(raw ?? 'short_text')
.trim()
.toLowerCase()
.replace(/-/g, '_')
.replace(/\s+/g, '_');
}
function dropdownOptions(field: ClickUpField): Array<{ label: string; value: string }> {
const tc = field.type_config as Record<string, unknown> | undefined;
if (!tc) return [];
let options = tc.options as unknown[] | undefined;
if (!options?.length && Array.isArray(tc.dropdowns)) {
const d0 = (tc.dropdowns as unknown[])[0] as Record<string, unknown> | undefined;
options = d0?.options as unknown[] | undefined;
}
if (!Array.isArray(options)) return [];
const out: Array<{ label: string; value: string }> = [];
for (const o of options) {
if (!o || typeof o !== 'object') continue;
const ob = o as Record<string, unknown>;
const name = String(ob.name ?? ob.label ?? '');
const idRaw = ob.id;
const id = idRaw != null && idRaw !== '' ? String(idRaw) : '';
const oi = ob.orderindex;
const orderindex =
typeof oi === 'number' ? oi : typeof oi === 'string' ? parseInt(oi, 10) : NaN;
const fallback = Number.isFinite(orderindex) ? String(orderindex) : '';
const value = id || fallback;
if (!value) continue;
out.push({ label: name, value });
}
return out;
}
function fieldUnsupported(ft: string): boolean {
return ['tasks', 'user', 'users'].includes(ft);
}
/** Target list for a list_relationship field (ClickUp type_config varies by version). */
function linkedListIdFromRelationshipField(field: ClickUpField): 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 directKeys = [
'linked_list_id',
'list_id',
'related_list_id',
'relationship_list_id',
'relation_list_id',
'target_list_id',
'resource_id',
];
for (const k of directKeys) {
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;
}
}
if (String(tc.resource_type ?? '').toLowerCase() === 'list') {
const id = asId(tc.resource_id);
if (id) return id;
}
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;
}
const nestedKeys = ['list', 'linked_list', 'related_list', 'resource', 'related_resource'] as const;
for (const k of nestedKeys) {
const raw = tc[k];
if (raw == null) continue;
const id = asId(raw) ?? (typeof raw === 'object' ? asId((raw as Record<string, unknown>).id) : null);
if (id) return id;
}
return null;
}
/** ClickUp relationship / tasks-style fields: { add: taskIds, rem: [] }. */
function relationshipPayloadFromValue(value: unknown): { add: string[]; rem: string[] } | null {
if (value == null) return null;
if (isRef(value)) return null;
if (isValue(value)) {
const v = value.value;
if (v === '' || v === null || v === undefined) return null;
if (typeof v === 'object' && v !== null && 'add' in v && Array.isArray((v as { add: unknown }).add)) {
return v as { add: string[]; rem: string[] };
}
if (typeof v === 'string' && v) return { add: [v], rem: [] };
}
if (typeof value === 'object' && value !== null && 'add' in value) {
return value as { add: string[]; rem: string[] };
}
if (typeof value === 'string' && value) return { add: [value], rem: [] };
return null;
}
function selectedTaskIdFromRelationship(value: unknown): string {
const p = relationshipPayloadFromValue(value);
if (!p?.add?.length) return '';
return String(p.add[0]);
}
function ClickUpListRelationshipFieldRow({
fname,
connectionId,
request,
linkedListId,
value,
onChange,
}: {
fname: string;
connectionId: string;
request: ApiRequestFunction;
linkedListId: string;
value: unknown;
onChange: (v: unknown) => void;
}) {
const dataFlow = useAutomation2DataFlow();
const [tasks, setTasks] = useState<Array<{ id: string; name: string }>>([]);
const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const hasSources =
dataFlow &&
dataFlow.getAvailableSourceIds().some((id) => {
const n = dataFlow.nodes.find((x) => x.id === id);
return n?.type !== 'trigger.manual';
});
useEffect(() => {
let cancelled = false;
setLoading(true);
setLoadError(null);
loadClickupListTasksForDropdown(request, connectionId, linkedListId)
.then((rows) => {
if (!cancelled) setTasks(rows);
})
.catch(() => {
if (!cancelled) {
setTasks([]);
setLoadError('Aufgaben konnten nicht geladen werden.');
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [request, connectionId, linkedListId]);
const sel = selectedTaskIdFromRelationship(value);
return (
<div className={styles.dynamicValueField}>
<label>{fname}</label>
{loadError ? (
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #c00)' }}>{loadError}</p>
) : null}
{hasSources ? (
<div style={{ marginBottom: 8 }}>
<StatischKontextSelect
value={value}
onChange={onChange}
placeholder="— Quelle wählen —"
staticLabel="Statisch (Aufgabe wählen)"
/>
</div>
) : null}
{shouldShowStaticControl(value, Boolean(hasSources)) && (
<>
{loading ? (
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #666)' }}>
Verknüpfte Aufgaben werden geladen
</p>
) : (
<select
value={sel}
onChange={(e) => {
const tid = e.target.value;
if (!tid) onChange(createValue(''));
else onChange(createValue({ add: [tid], rem: [] }));
}}
>
<option value=""> Aufgabe wählen </option>
{tasks.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
)}
</>
)}
</div>
);
}
/** Selected dropdown option id (string); supports legacy numeric orderindex stored as number. */
function staticDropdownStringValue(value: unknown): string {
if (value == null) return '';
if (isRef(value)) return '';
if (isValue(value)) {
const v = value.value;
if (v === '' || v === null || v === undefined) return '';
return String(v);
}
return String(value ?? '');
}
function staticCheckbox(value: unknown): boolean {
if (value == null) return false;
if (isValue(value)) return Boolean(value.value);
return Boolean(value);
}
/** One custom field row: static and/or context ref. */
function fieldConfigHintMessage(fname: string, ft: string): string {
return `${fname} (${ft})`;
}
function ClickUpCustomFieldRow({
field,
value,
onChange,
connectionId,
request,
parentListId,
}: {
field: ClickUpField;
value: unknown;
onChange: (v: unknown) => void;
connectionId?: string;
request?: ApiRequestFunction;
/** List where the new task is created — fallback if relationship omits linked list (same-list). */
parentListId?: string;
}) {
const dataFlow = useAutomation2DataFlow();
const fid = String(field.id ?? '');
const fname = String(field.name ?? fid);
const ft = normalizeFieldType(field.type);
const hasSources =
dataFlow &&
dataFlow.getAvailableSourceIds().some((id) => {
const n = dataFlow.nodes.find((x) => x.id === id);
return n?.type !== 'trigger.manual';
});
if (fieldUnsupported(ft)) {
return (
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #666)' }}>
<strong>{fname}</strong> ({ft}): nur über Zusätzliches JSON setzbar.
</p>
);
}
if (ft === 'list_relationship') {
const linkedId =
linkedListIdFromRelationshipField(field) ?? (parentListId?.trim() || null);
if (!connectionId || !request) {
return (
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #666)' }}>
<strong>{fname}</strong> (List-Beziehung): Verbindung fehlt Feld kann nicht geladen werden.
</p>
);
}
if (!linkedId) {
return (
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #666)' }}>
<strong>{fname}</strong> (List-Beziehung): Verknüpfte Liste nicht in den Felddaten in ClickUp
prüfen oder Support.
</p>
);
}
return (
<ClickUpListRelationshipFieldRow
fname={fname}
connectionId={connectionId}
request={request}
linkedListId={linkedId}
value={value}
onChange={onChange}
/>
);
}
if (ft === 'drop_down' || ft === 'dropdown') {
const opts = dropdownOptions(field);
if (!opts.length) {
return (
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #666)' }}>
<strong>{fieldConfigHintMessage(fname, ft)}</strong>: Keine Dropdown-Optionen in den
Felddaten (type_config.options).
</p>
);
}
return (
<div className={styles.dynamicValueField}>
<label>{fname}</label>
{hasSources ? (
<div style={{ marginBottom: 8 }}>
<StatischKontextSelect
value={value}
onChange={onChange}
placeholder="— Quelle wählen —"
/>
</div>
) : null}
{shouldShowStaticControl(value, Boolean(hasSources)) && (
<select
value={staticDropdownStringValue(value)}
onChange={(e) => {
const v = e.target.value;
if (v === '') onChange(createValue(''));
else onChange(createValue(v));
}}
>
<option value=""> Option wählen </option>
{opts.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
)}
</div>
);
}
if (ft === 'checkbox') {
const checked = staticCheckbox(value);
return (
<div className={styles.dynamicValueField}>
<label>{fname}</label>
{hasSources ? (
<div style={{ marginBottom: 8 }}>
<StatischKontextSelect
value={value}
onChange={onChange}
placeholder="— Quelle wählen —"
/>
</div>
) : null}
{shouldShowStaticControl(value, Boolean(hasSources)) && (
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(createValue(e.target.checked))}
/>
<span>Ja</span>
</label>
)}
</div>
);
}
if (ft === 'date') {
const ms = isValue(value)
? Number(value.value)
: typeof value === 'number'
? value
: 0;
const day =
ms > 0
? new Date(ms).toISOString().slice(0, 10)
: '';
return (
<div className={styles.dynamicValueField}>
<label>{fname}</label>
{hasSources ? (
<div style={{ marginBottom: 8 }}>
<StatischKontextSelect
value={value}
onChange={onChange}
placeholder="— Quelle wählen —"
/>
</div>
) : null}
{shouldShowStaticControl(value, Boolean(hasSources)) && (
<input
type="date"
value={day}
onChange={(e) => {
const t = e.target.value ? new Date(e.target.value).getTime() : '';
onChange(createValue(t === '' ? '' : t));
}}
/>
)}
</div>
);
}
if (ft === 'number' || ft === 'currency') {
return (
<HybridStaticRefField
label={fname}
value={value}
onChange={onChange}
inputType="number"
placeholder={ft === 'currency' ? 'z. B. 1234.56' : undefined}
/>
);
}
if (ft === 'text' || ft === 'long_text' || ft === 'short_text' || ft === 'email' || ft === 'phone' || ft === 'url') {
const multiline = ft === 'text' || ft === 'long_text';
return (
<HybridStaticRefField
label={fname}
value={value}
onChange={onChange}
multiline={multiline}
placeholder={fname}
/>
);
}
return (
<HybridStaticRefField
label={`${fname} (${ft})`}
value={value}
onChange={onChange}
multiline
/>
);
}
function ClickUpTaskDueDateRow({
value,
onChange,
}: {
value: unknown;
onChange: (v: unknown) => void;
}) {
const dataFlow = useAutomation2DataFlow();
const hasSources =
dataFlow &&
dataFlow.getAvailableSourceIds().some((id) => {
const n = dataFlow.nodes.find((x) => x.id === id);
return n?.type !== 'trigger.manual';
});
const ms = isValue(value)
? Number(value.value)
: typeof value === 'number'
? value
: 0;
const day = ms > 0 ? new Date(ms).toISOString().slice(0, 10) : '';
return (
<div className={styles.dynamicValueField}>
<label>Fälligkeit</label>
{hasSources ? (
<div style={{ marginBottom: 8 }}>
<StatischKontextSelect
value={value}
onChange={onChange}
placeholder="— Quelle wählen —"
/>
</div>
) : null}
{shouldShowStaticControl(value, Boolean(hasSources)) && (
<input
type="date"
value={day}
onChange={(e) => {
const t = e.target.value ? new Date(e.target.value).getTime() : '';
onChange(createValue(t === '' ? '' : t));
}}
/>
)}
</div>
);
}
function ClickUpTimeEstimateHoursRow({
value,
onChange,
}: {
value: unknown;
onChange: (v: unknown) => void;
}) {
const dataFlow = useAutomation2DataFlow();
const hasSources =
dataFlow &&
dataFlow.getAvailableSourceIds().some((id) => {
const n = dataFlow.nodes.find((x) => x.id === id);
return n?.type !== 'trigger.manual';
});
const h = isValue(value)
? Number(value.value)
: typeof value === 'number'
? value
: 0;
const hours = h > 0 && Number.isFinite(h) ? h : '';
return (
<div className={styles.dynamicValueField}>
<label>Zeitschätzung (Stunden)</label>
{hasSources ? (
<div style={{ marginBottom: 8 }}>
<StatischKontextSelect
value={value}
onChange={onChange}
placeholder="— Quelle wählen —"
/>
</div>
) : null}
{shouldShowStaticControl(value, Boolean(hasSources)) && (
<input
type="number"
min={0}
step={0.25}
value={hours === '' ? '' : hours}
onChange={(e) => {
const raw = e.target.value;
if (raw === '') onChange(createValue(''));
else {
const n = parseFloat(raw);
onChange(createValue(Number.isFinite(n) ? n : ''));
}
}}
/>
)}
</div>
);
}
/** One row for clickup.updateTask — field + value (+ custom field id). */
export type ClickUpTaskUpdateEntryRow = {
rowId: string;
fieldKey: string;
customFieldId: unknown;
value: unknown;
};
function newClickUpTaskUpdateEntryRow(): ClickUpTaskUpdateEntryRow {
return {
rowId: `row_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`,
fieldKey: 'name',
customFieldId: createValue(''),
value: createValue(''),
};
}
const UPDATE_TASK_FIELD_OPTIONS: Array<{ value: string; label: string }> = [
{ value: 'name', label: 'Titel (name)' },
{ value: 'description', label: 'Beschreibung' },
{ value: 'status', label: 'Status' },
{ value: 'priority', label: 'Priorität (14)' },
{ value: 'due_date', label: 'Fälligkeit (Datum oder ms)' },
{ value: 'time_estimate_h', label: 'Zeitschätzung (Stunden)' },
{ value: 'time_estimate_ms', label: 'Zeitschätzung (ms)' },
{ value: 'assignees', label: 'Zugewiesene' },
{ value: 'custom_field', label: 'Benutzerdefiniertes Feld' },
];
function normalizeTaskUpdateEntries(raw: unknown): ClickUpTaskUpdateEntryRow[] {
if (!Array.isArray(raw)) return [];
return raw.map((r, i) => {
if (r && typeof r === 'object' && !Array.isArray(r)) {
const o = r as Record<string, unknown>;
return {
rowId: String(o.rowId ?? `row_${i}`),
fieldKey: String(o.fieldKey ?? 'name'),
customFieldId: o.customFieldId ?? createValue(''),
value: o.value ?? createValue(''),
};
}
return newClickUpTaskUpdateEntryRow();
});
}
/** Static task id for dropdown (refs → empty). */
function taskIdStaticForDropdown(taskId: unknown): string {
if (isRef(taskId)) return '';
if (isValue(taskId)) return String(taskId.value ?? '').trim();
if (typeof taskId === 'string' || typeof taskId === 'number') return String(taskId).trim();
return '';
}
/** Pick an existing task from a list (same API as Form „ClickUp-Aufgabe“). */
function ClickUpTaskFromListDropdown({
connectionId,
listId,
request,
taskId,
onSetTaskId,
}: {
connectionId: string;
listId: string;
request: ApiRequestFunction;
taskId: unknown;
onSetTaskId: (v: unknown) => void;
}) {
const [tasks, setTasks] = useState<Array<{ id: string; name: string }>>([]);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
const cid = connectionId.trim();
const lid = listId.trim();
if (!cid || !lid) {
setTasks([]);
return;
}
let cancelled = false;
setLoading(true);
setErr(null);
loadClickupListTasksForDropdown(request, cid, lid)
.then((rows) => {
if (!cancelled) setTasks(rows);
})
.catch(() => {
if (!cancelled) {
setTasks([]);
setErr('Aufgaben konnten nicht geladen werden.');
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [request, connectionId, listId]);
const sel = taskIdStaticForDropdown(taskId);
return (
<div className={styles.dynamicValueField} style={{ marginTop: 10 }}>
<label>Vorhandene Aufgabe in der gewählten Liste</label>
{err ? (
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #c00)' }}>{err}</p>
) : null}
{loading ? (
<span style={{ fontSize: '0.85rem', color: '#666' }}>Aufgaben werden geladen</span>
) : (
<select
value={sel}
onChange={(e) => {
const id = e.target.value;
if (!id) onSetTaskId(createValue(''));
else onSetTaskId(createValue(id));
}}
>
<option value=""> Aufgabe aus ClickUp wählen </option>
{tasks.map((t) => (
<option key={t.id} value={t.id}>
{t.name || t.id}
</option>
))}
</select>
)}
<p style={{ fontSize: '0.75rem', color: 'var(--text-secondary, #666)', marginTop: 6, marginBottom: 0 }}>
Workspace und Liste oben wählen. Oder Task-ID unten per Kontext-Referenz setzen (dann entfällt die
Listenauswahl oben).
</p>
</div>
);
}
function ClickUpUpdateTaskEntriesEditor({
rows,
onChangeRows,
connectionId,
listId,
pathForListId,
request,
taskIdParam,
teamMembers,
teamMembersLoading,
parentListStatuses,
parentListStatusesLoading,
}: {
rows: ClickUpTaskUpdateEntryRow[];
onChangeRows: (next: ClickUpTaskUpdateEntryRow[]) => void;
connectionId: string;
/** Resolved list id (param or parsed from path). */
listId: string;
/** Raw path — fallback to parse /list/{id}/ when listId param empty. */
pathForListId: string;
request: ApiRequestFunction;
taskIdParam: unknown;
teamMembers: Array<{ id: string; username: string }>;
teamMembersLoading: boolean;
/** Same list fetch as parent — avoids empty child dropdown when listId is set but child effect lags. */
parentListStatuses: Array<{ status: string; orderindex: number }>;
parentListStatusesLoading: boolean;
}) {
const dataFlow = useAutomation2DataFlow();
const [resolvedStatuses, setResolvedStatuses] = useState<Array<{ status: string; orderindex: number }>>(
[]
);
const [resolvedStatusesLoading, setResolvedStatusesLoading] = useState(false);
const previewFingerprint = useMemo(
() => JSON.stringify(dataFlow?.nodeOutputsPreview ?? {}),
[dataFlow?.nodeOutputsPreview]
);
/** GET /list/{id}; wenn keine List-ID: GET /task/{id} nur bei plausibler Task-ID (Vorschau/Statisch). */
useEffect(() => {
let cancelled = false;
(async () => {
const cid = (connectionId || '').trim();
if (!cid || !request) {
setResolvedStatuses([]);
setResolvedStatusesLoading(false);
return;
}
let lid = (listId || '').trim();
if (!lid) {
lid = parseListIdFromPath(pathForListId) ?? '';
}
const lidFromListOrPath = lid;
if (!lid && taskIdParam != null && dataFlow) {
let tid = '';
if (isRef(taskIdParam)) {
const r = resolvePreview(taskIdParam, dataFlow.nodeOutputsPreview);
tid = r != null && r !== '' ? String(r) : '';
} else if (isValue(taskIdParam)) {
tid = String(taskIdParam.value ?? '').trim();
} else if (typeof taskIdParam === 'string' || typeof taskIdParam === 'number') {
tid = String(taskIdParam).trim();
}
if (tid && isPlausibleClickUpTaskId(tid)) {
setResolvedStatusesLoading(true);
try {
const task = await fetchClickupTask(request, cid, tid);
if (cancelled) return;
if (
task &&
typeof task === 'object' &&
!('error' in task && (task as { error?: unknown }).error)
) {
const fromTask = listIdFromClickUpTaskApi(task as Record<string, unknown>) || '';
if (fromTask) lid = fromTask;
}
} catch {
lid = lidFromListOrPath;
}
}
}
if (!lid) {
setResolvedStatuses([]);
setResolvedStatusesLoading(false);
return;
}
setResolvedStatusesLoading(true);
try {
const data = await fetchClickupList(request, cid, lid);
if (cancelled) return;
if (
data &&
typeof data === 'object' &&
'error' in data &&
(data as { error?: unknown }).error
) {
setResolvedStatuses([]);
} else {
setResolvedStatuses(parseListStatuses(data as Record<string, unknown>));
}
} catch {
if (!cancelled) setResolvedStatuses([]);
} finally {
if (!cancelled) setResolvedStatusesLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [connectionId, listId, pathForListId, request, taskIdParam, previewFingerprint, dataFlow]);
const statusOptions = useMemo(() => {
if (resolvedStatuses.length > 0) return resolvedStatuses;
return parentListStatuses ?? [];
}, [resolvedStatuses, parentListStatuses]);
const statusOptionsLoading =
resolvedStatusesLoading ||
(resolvedStatuses.length === 0 &&
parentListStatusesLoading &&
Boolean((listId || '').trim() || parseListIdFromPath(pathForListId)));
const updateRow = (rowId: string, patch: Partial<ClickUpTaskUpdateEntryRow>) => {
onChangeRows(rows.map((r) => (r.rowId === rowId ? { ...r, ...patch } : r)));
};
const removeRow = (rowId: string) => {
onChangeRows(rows.filter((r) => r.rowId !== rowId));
};
const assigneeIdsFromValue = (v: unknown): string[] => {
if (isRef(v)) return [];
const raw = isValue(v) ? v.value : v;
if (Array.isArray(raw)) return raw.map((x) => String(x)).filter(Boolean);
if (typeof raw === 'string' && raw.trim()) {
try {
const p = JSON.parse(raw) as unknown;
if (Array.isArray(p)) return p.map((x) => String(x)).filter(Boolean);
} catch {
return [];
}
}
return [];
};
const renderValueEditor = (row: ClickUpTaskUpdateEntryRow) => {
const fk = row.fieldKey;
const commonHybrid = (multiline?: boolean, placeholder?: string, inputType?: 'text' | 'number') => (
<HybridStaticRefField
label="Oder Referenz (ohne Formular-Payload)"
value={row.value}
onChange={(v) => updateRow(row.rowId, { value: v })}
multiline={multiline}
inputType={inputType}
placeholder={placeholder}
pathPickMode="exclude_forms"
/>
);
if (fk === 'custom_field') {
return (
<>
<HybridStaticRefField
label="ClickUp-Feld-ID"
value={row.customFieldId}
onChange={(v) => updateRow(row.rowId, { customFieldId: v })}
placeholder="Feld-ID"
pathPickMode="exclude_forms"
/>
<HybridStaticRefField
label="Neuer Wert"
value={row.value}
onChange={(v) => updateRow(row.rowId, { value: v })}
multiline
placeholder="Wert"
pathPickMode="exclude_forms"
/>
</>
);
}
if (fk === 'status') {
if (isRef(row.value)) {
return (
<HybridStaticRefField
label="Status (Referenz)"
value={row.value}
onChange={(v) => updateRow(row.rowId, { value: v })}
placeholder="Kontext mit Status-String"
pathPickMode="exclude_forms"
/>
);
}
const staticVal = isValue(row.value)
? String(row.value.value ?? '')
: String(row.value ?? '');
if (statusOptionsLoading) {
return (
<div className={styles.dynamicValueField}>
<label>Status (wie in ClickUp)</label>
<select disabled value="">
<option value="">Status werden geladen</option>
</select>
</div>
);
}
return (
<div className={styles.dynamicValueField}>
<label>Status (wie in ClickUp)</label>
<select
value={staticVal}
onChange={(e) => updateRow(row.rowId, { value: createValue(e.target.value) })}
disabled={statusOptions.length === 0}
>
<option value="">
{statusOptions.length === 0
? '— Keine Status geladen —'
: '— Status wählen —'}
</option>
{statusOptions.map((s) => (
<option key={s.status} value={s.status}>
{s.status}
</option>
))}
</select>
{statusOptions.length === 0 && (
<p
style={{
fontSize: '0.75rem',
color: 'var(--text-secondary, #666)',
marginTop: 6,
marginBottom: 0,
}}
>
<a href="#clickup-update-task-list-anchor" style={{ fontWeight: 600 }}>
Liste wählen
</a>{' '}
(Kasten direkt über den Zeilen) oder gültige Task-ID mit Vorschau; alternativ Path mit{' '}
<code>/list//</code>.
</p>
)}
</div>
);
}
if (fk === 'priority') {
const pv = isRef(row.value) ? '' : isValue(row.value) ? String(row.value.value ?? '') : String(row.value ?? '');
return (
<div className={styles.dynamicValueField}>
<label>Priorität</label>
<select
value={pv && ['1', '2', '3', '4'].includes(pv) ? pv : ''}
onChange={(e) => updateRow(row.rowId, { value: createValue(e.target.value) })}
>
<option value=""> wählen </option>
<option value="1">1 Dringend</option>
<option value="2">2 Hoch</option>
<option value="3">3 Normal</option>
<option value="4">4 Niedrig</option>
</select>
</div>
);
}
if (fk === 'due_date') {
return (
<>
<ClickUpTaskDueDateRow
value={row.value}
onChange={(v) => updateRow(row.rowId, { value: v })}
/>
{commonHybrid(false, 'Alternativ nur Referenz')}
</>
);
}
if (fk === 'time_estimate_h') {
return (
<>
<ClickUpTimeEstimateHoursRow
value={row.value}
onChange={(v) => updateRow(row.rowId, { value: v })}
/>
{commonHybrid(false, 'Alternativ: Referenz (Stunden)')}
</>
);
}
if (fk === 'time_estimate_ms') {
return commonHybrid(false, 'ms', 'number');
}
if (fk === 'assignees') {
const selected = new Set(assigneeIdsFromValue(row.value));
return (
<>
{teamMembersLoading ? (
<span style={{ fontSize: '0.85rem', color: '#666' }}>Mitglieder werden geladen</span>
) : teamMembers.length === 0 ? (
<p style={{ fontSize: '0.8rem', color: '#666' }}>
Keine Mitglieder Workspace oben wählen oder JSON / Referenz unten.
</p>
) : (
<div
className={styles.dynamicValueField}
style={{
maxHeight: 160,
overflowY: 'auto',
border: '1px solid var(--border-color, #e0e0e0)',
borderRadius: 4,
padding: 8,
}}
>
<label>Zugewiesene</label>
{teamMembers.map((m) => (
<label
key={m.id}
style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}
>
<input
type="checkbox"
checked={selected.has(m.id)}
onChange={() => {
const next = new Set(selected);
if (next.has(m.id)) next.delete(m.id);
else next.add(m.id);
updateRow(row.rowId, {
value: createValue([...next].map((id) => Number(id))),
});
}}
/>
<span>{m.username}</span>
</label>
))}
</div>
)}
{commonHybrid(false, '[123,456] oder JSON-Array')}
</>
);
}
if (fk === 'description') {
return (
<HybridStaticRefField
label="Wert"
value={row.value}
onChange={(v) => updateRow(row.rowId, { value: v })}
multiline
placeholder="Beschreibung"
pathPickMode="exclude_forms"
/>
);
}
if (fk === 'name') {
return (
<HybridStaticRefField
label="Wert"
value={row.value}
onChange={(v) => updateRow(row.rowId, { value: v })}
placeholder="Titel"
pathPickMode="exclude_forms"
/>
);
}
return commonHybrid(false);
};
return (
<div style={{ marginTop: 12 }}>
<label style={{ fontWeight: 600, display: 'block', marginBottom: 8 }}>
Felder zum Aktualisieren
</label>
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #666)', marginBottom: 10 }}>
Pro Zeile ein Feld. Status und Priorität wie in ClickUp (Dropdown). Referenzen ohne
Formular-Payload (nur Knoten wie Aufgabe erstellen).
</p>
{rows.map((row) => (
<div
key={row.rowId}
style={{
border: '1px solid var(--border-color, #e0e0e0)',
borderRadius: 6,
padding: 10,
marginBottom: 10,
background: 'var(--bg-secondary, #f8f9fa)',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
flexWrap: 'wrap',
marginBottom: 8,
}}
>
<label style={{ flex: '0 0 auto' }}>Feld</label>
<select
value={row.fieldKey}
onChange={(e) =>
updateRow(row.rowId, {
fieldKey: e.target.value,
customFieldId: createValue(''),
value: createValue(''),
})
}
>
{UPDATE_TASK_FIELD_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<button type="button" className={styles.retryButton} onClick={() => removeRow(row.rowId)}>
Zeile entfernen
</button>
</div>
{renderValueEditor(row)}
</div>
))}
<button
type="button"
className={styles.retryButton}
onClick={() => onChangeRows([...rows, newClickUpTaskUpdateEntryRow()])}
>
Feld zum Aktualisieren hinzufügen
</button>
</div>
);
}
export const ClickUpNodeConfig: React.FC<NodeConfigRendererProps> = ({
params,
updateParam,
instanceId,
request,
mergeNodeParameters,
nodeType = 'clickup.listTasks',
}) => {
const dataFlow = useAutomation2DataFlow();
const lastFormSyncSigRef = useRef<string>('');
const [connections, setConnections] = useState<UserConnection[]>([]);
const [browseOpen, setBrowseOpen] = useState(false);
const [connectionsLoading, setConnectionsLoading] = useState(false);
const [searchTeams, setSearchTeams] = useState<Array<{ id: string; name: string }>>([]);
const [searchTeamsLoading, setSearchTeamsLoading] = useState(false);
const [searchLists, setSearchLists] = useState<Array<{ id: string; name: string }>>([]);
const [searchListsLoading, setSearchListsLoading] = useState(false);
const [listFields, setListFields] = useState<ClickUpField[]>([]);
const [listFieldsLoading, setListFieldsLoading] = useState(false);
const [listStatuses, setListStatuses] = useState<Array<{ status: string; orderindex: number }>>([]);
const [listStatusesLoading, setListStatusesLoading] = useState(false);
const [teamMembers, setTeamMembers] = useState<Array<{ id: string; username: string }>>([]);
const [teamMembersLoading, setTeamMembersLoading] = useState(false);
const connectionId = (params.connectionId as string) ?? '';
const path = (params.path as string) ?? '';
const teamIdParam = (params.teamId as string) ?? '';
const listIdParam = (params.listId as string) ?? '';
/** listId param or list id parsed from path — loads statuses when Task-GET fails (ref / 404). */
const effectiveListIdForStatuses = useMemo(() => {
const lid = ((params.listId as string) ?? '').trim();
if (lid) return lid;
return parseListIdFromPath(path) ?? '';
}, [params.listId, path]);
const customFieldValues = useMemo(
() => (params.customFieldValues as Record<string, unknown> | undefined) ?? {},
[params.customFieldValues]
);
const taskAssigneeIds = useMemo(() => {
const raw = params.taskAssigneeIds;
if (Array.isArray(raw)) {
return raw.map((x) => String(x)).filter(Boolean);
}
if (typeof raw === 'string' && raw.trim()) {
try {
const p = JSON.parse(raw) as unknown;
if (Array.isArray(p)) return p.map((x) => String(x)).filter(Boolean);
} catch {
return [];
}
}
return [];
}, [params.taskAssigneeIds]);
/** Stunden (API); Fallback: alte Speicherung in ms → Anzeige in Stunden */
const timeEstimateHoursValue = useMemo(() => {
const th = params.taskTimeEstimateHours;
if (th != null && th !== '') return th;
const tm = params.taskTimeEstimateMs;
if (isValue(tm) && typeof tm.value === 'number' && tm.value > 0) {
return createValue(tm.value / (3600 * 1000));
}
if (typeof tm === 'number' && tm > 0) return createValue(tm / (3600 * 1000));
return createValue('');
}, [params.taskTimeEstimateHours, params.taskTimeEstimateMs]);
const runFormSyncFromList = useCallback(
(force: boolean) => {
if (!mergeNodeParameters || !connectionId || !teamIdParam || !listIdParam) return;
if (!dataFlow) return;
if (listFieldsLoading) return;
if (listStatusesLoading) return;
const formNode = findClosestUpstreamFormNode(
dataFlow.currentNodeId,
dataFlow.nodes,
dataFlow.connections
);
if (!formNode) return;
const sig = `${listIdParam}|${listFields.map((f) => String((f as ClickUpFieldLike).id ?? '')).sort().join(',')}|${listStatuses.map((s) => s.status).join('\x00')}`;
if (!force && lastFormSyncSigRef.current === sig) return;
lastFormSyncSigRef.current = sig;
const built = buildSyncFromClickUpList({
formNodeId: formNode.id,
listFields: listFields as ClickUpFieldLike[],
listStatuses,
connectionId,
teamId: teamIdParam,
listId: listIdParam,
});
if (formNode.type === 'input.form') {
mergeNodeParameters(formNode.id, { fields: built.inputFormFields });
} else if (formNode.type === 'trigger.form') {
mergeNodeParameters(formNode.id, { formFields: built.triggerFormFields });
}
for (const [k, v] of Object.entries(built.clickupPatch)) {
updateParam(k, v);
}
updateParam('taskTimeEstimateMs', createValue(''));
},
[
mergeNodeParameters,
connectionId,
teamIdParam,
listIdParam,
listFields,
listFieldsLoading,
listStatuses,
listStatusesLoading,
dataFlow,
updateParam,
]
);
const clickupConnections = useMemo(
() => connections.filter((c) => c.authority === 'clickup'),
[connections]
);
useEffect(() => {
if (instanceId && request) {
setConnectionsLoading(true);
fetchConnections(request, instanceId)
.then(setConnections)
.catch(() => setConnections([]))
.finally(() => setConnectionsLoading(false));
}
}, [instanceId, request]);
useEffect(() => {
if (!needsTeamList(nodeType) || !instanceId || !request || !connectionId) {
setSearchTeams([]);
return;
}
let cancelled = false;
setSearchTeamsLoading(true);
fetchBrowse(request, instanceId, connectionId, 'clickup', '/')
.then((r) => {
if (cancelled) return;
const rows: Array<{ id: string; name: string }> = [];
for (const e of r.items ?? []) {
const tid = teamIdFromBrowseEntry(e);
if (tid) rows.push({ id: tid, name: e.name });
}
setSearchTeams(rows);
})
.catch(() => {
if (!cancelled) setSearchTeams([]);
})
.finally(() => {
if (!cancelled) setSearchTeamsLoading(false);
});
return () => {
cancelled = true;
};
}, [nodeType, instanceId, request, connectionId]);
useEffect(() => {
if (!needsTeamList(nodeType) || !instanceId || !request || !connectionId || !teamIdParam) {
setSearchLists([]);
return;
}
let cancelled = false;
setSearchListsLoading(true);
const teamPath = `/team/${teamIdParam}`;
collectListsUnderTeam(request, instanceId, connectionId, teamPath)
.then((rows) => {
if (!cancelled) setSearchLists(rows);
})
.catch(() => {
if (!cancelled) setSearchLists([]);
})
.finally(() => {
if (!cancelled) setSearchListsLoading(false);
});
return () => {
cancelled = true;
};
}, [nodeType, instanceId, request, connectionId, teamIdParam]);
useEffect(() => {
if (nodeType !== 'clickup.createTask' || !connectionId || !listIdParam || !request) {
setListFields([]);
return;
}
let cancelled = false;
setListFieldsLoading(true);
fetchClickupListFields(request, connectionId, listIdParam)
.then((data) => {
if (cancelled) return;
const raw = data?.fields;
const list = Array.isArray(raw)
? raw
: Array.isArray(data)
? (data as ClickUpField[])
: [];
setListFields(list as ClickUpField[]);
})
.catch(() => {
if (!cancelled) setListFields([]);
})
.finally(() => {
if (!cancelled) setListFieldsLoading(false);
});
return () => {
cancelled = true;
};
}, [nodeType, connectionId, listIdParam, request]);
useEffect(() => {
if (
(nodeType !== 'clickup.createTask' && nodeType !== 'clickup.updateTask') ||
!connectionId ||
!effectiveListIdForStatuses ||
!request
) {
setListStatuses([]);
return;
}
let cancelled = false;
setListStatusesLoading(true);
fetchClickupList(request, connectionId, effectiveListIdForStatuses)
.then((data) => {
if (cancelled) return;
if (data && typeof data === 'object' && 'error' in data && (data as { error?: unknown }).error) {
setListStatuses([]);
return;
}
setListStatuses(parseListStatuses(data as Record<string, unknown>));
})
.catch(() => {
if (!cancelled) setListStatuses([]);
})
.finally(() => {
if (!cancelled) setListStatusesLoading(false);
});
return () => {
cancelled = true;
};
}, [nodeType, connectionId, effectiveListIdForStatuses, request]);
useEffect(() => {
if (
(nodeType !== 'clickup.createTask' && nodeType !== 'clickup.updateTask') ||
!connectionId ||
!teamIdParam ||
!request
) {
setTeamMembers([]);
return;
}
let cancelled = false;
setTeamMembersLoading(true);
fetchClickupTeam(request, connectionId, teamIdParam)
.then((data) => {
if (cancelled) return;
if (data && typeof data === 'object' && 'error' in data && (data as { error?: unknown }).error) {
setTeamMembers([]);
return;
}
setTeamMembers(parseTeamMembers(data as Record<string, unknown>));
})
.catch(() => {
if (!cancelled) setTeamMembers([]);
})
.finally(() => {
if (!cancelled) setTeamMembersLoading(false);
});
return () => {
cancelled = true;
};
}, [nodeType, connectionId, teamIdParam, request]);
useEffect(() => {
if (nodeType !== 'clickup.createTask') return;
if (params.autoSyncFormWithList === false) return;
if (!mergeNodeParameters) return;
runFormSyncFromList(false);
}, [
nodeType,
params.autoSyncFormWithList,
mergeNodeParameters,
listIdParam,
listFields,
listFieldsLoading,
connectionId,
teamIdParam,
runFormSyncFromList,
]);
const loadChildren = useCallback(
async (pathToLoad: string): Promise<BrowseEntry[]> => {
if (!instanceId || !request || !connectionId) return [];
const r = await fetchBrowse(request, instanceId, connectionId, 'clickup', pathToLoad);
return r?.items ?? [];
},
[instanceId, request, connectionId]
);
const selectPath = useCallback(
(p: string) => {
updateParam('path', p);
setBrowseOpen(false);
},
[updateParam]
);
const setCustomField = useCallback(
(fieldId: string, v: unknown) => {
updateParam('customFieldValues', { ...customFieldValues, [fieldId]: v });
},
[customFieldValues, updateParam]
);
const showBrowse = connectionId && (isListPicker(nodeType) || isTaskPicker(nodeType));
return (
<>
<div>
<label>Connection (ClickUp)</label>
<select
value={connectionId}
onChange={(e) => updateParam('connectionId', e.target.value)}
disabled={connectionsLoading}
>
<option value="">{connectionsLoading ? 'Loading...' : 'Select connection'}</option>
{clickupConnections.map((c) => (
<option key={c.id} value={c.id}>
{c.externalUsername ?? c.id}
</option>
))}
</select>
</div>
{nodeType === 'clickup.searchTasks' && (
<>
<div>
<label>Workspace</label>
<select
value={teamIdParam}
onChange={(e) => {
const v = e.target.value;
updateParam('teamId', v);
updateParam('listId', '');
}}
disabled={searchTeamsLoading || !connectionId}
>
<option value="">
{searchTeamsLoading ? 'Workspaces werden geladen…' : 'Workspace wählen…'}
</option>
{searchTeams.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
</div>
<div>
<label>Liste (Tabelle)</label>
<select
value={listIdParam}
onChange={(e) => updateParam('listId', e.target.value)}
disabled={!teamIdParam || searchListsLoading}
>
<option value="">
{searchListsLoading
? 'Listen werden geladen…'
: teamIdParam
? 'Alle Listen im Workspace'
: 'Zuerst Workspace wählen'}
</option>
{searchLists.map((L) => (
<option key={L.id} value={L.id}>
{L.name}
</option>
))}
</select>
</div>
<div>
<label>Suchbegriff</label>
<input
value={(params.query as string) ?? ''}
onChange={(e) => updateParam('query', e.target.value)}
placeholder="Stichwort für die Aufgabensuche"
/>
</div>
<details style={{ marginTop: 8 }}>
<summary style={{ cursor: 'pointer', fontSize: '0.875rem', color: 'var(--text-secondary, #555)' }}>
Erweitert
</summary>
<div style={{ marginTop: 8 }}>
<label>Seite (Pagination)</label>
<input
type="number"
min={0}
value={(params.page as number) ?? 0}
onChange={(e) => updateParam('page', parseInt(e.target.value, 10) || 0)}
/>
<div style={{ marginTop: 10 }}>
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 8, cursor: 'pointer' }}>
<input
type="checkbox"
checked={params.matchNameOnly !== false}
onChange={(e) => updateParam('matchNameOnly', e.target.checked)}
/>
<span>
Nur Aufgaben, deren Titel den Suchbegriff enthält (Standard: an aus für
Relevanzsuche inkl. Beschreibung)
</span>
</label>
</div>
{listIdParam ? (
<div style={{ marginTop: 10 }}>
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 8, cursor: 'pointer' }}>
<input
type="checkbox"
checked={Boolean(params.includeClosed)}
onChange={(e) => updateParam('includeClosed', e.target.checked)}
/>
<span>Erledigte Aufgaben einbeziehen (Listen-Suche)</span>
</label>
</div>
) : null}
<div style={{ marginTop: 10 }}>
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 8, cursor: 'pointer' }}>
<input
type="checkbox"
checked={Boolean(params.fullTaskData)}
onChange={(e) => updateParam('fullTaskData', e.target.checked)}
/>
<span>
Vollständige ClickUp-Rohdaten (alle Felder, sehr groß nur für Debugging oder Spezialfälle)
</span>
</label>
</div>
</div>
</details>
</>
)}
{nodeType === 'clickup.createTask' && (
<>
<div>
<label>Workspace</label>
<select
value={teamIdParam}
onChange={(e) => {
const v = e.target.value;
updateParam('teamId', v);
updateParam('listId', '');
updateParam('path', '');
updateParam('customFieldValues', {});
updateParam('taskStatus', '');
updateParam('taskAssigneeIds', []);
}}
disabled={searchTeamsLoading || !connectionId}
>
<option value="">
{searchTeamsLoading ? 'Workspaces werden geladen…' : 'Workspace wählen…'}
</option>
{searchTeams.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
</div>
<div>
<label>Liste</label>
<select
value={listIdParam}
onChange={(e) => {
const lid = e.target.value;
updateParam('listId', lid);
updateParam('customFieldValues', {});
updateParam('taskStatus', '');
if (teamIdParam && lid) {
updateParam('path', `/team/${teamIdParam}/list/${lid}`);
} else {
updateParam('path', '');
}
}}
disabled={!teamIdParam || searchListsLoading}
>
<option value="">{searchListsLoading ? 'Listen werden geladen…' : 'Liste wählen…'}</option>
{searchLists.map((L) => (
<option key={L.id} value={L.id}>
{L.name}
</option>
))}
</select>
</div>
{mergeNodeParameters && teamIdParam && listIdParam ? (
<div
style={{
marginTop: 12,
padding: 10,
borderRadius: 6,
border: '1px solid var(--border-color, #e0e0e0)',
background: 'var(--bg-secondary, #f8f9fa)',
}}
>
<label style={{ fontWeight: 600, display: 'block', marginBottom: 6 }}>
Formular mit dieser Liste abgleichen
</label>
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #666)', marginBottom: 8 }}>
Sucht den nächsten <strong>Formular</strong>-Knoten (<code>input.form</code> oder{' '}
<code>trigger.form</code>) stromaufwärts, legt dessen Felder wie die ClickUp-Liste an und
setzt hier die Datenquellen (<code>payload.</code>) auf dieses Formular. Anschließend
können Sie Felder im Formular-Knoten anpassen.
</p>
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 8, cursor: 'pointer', marginBottom: 8 }}>
<input
type="checkbox"
checked={params.autoSyncFormWithList !== false}
onChange={(e) => updateParam('autoSyncFormWithList', e.target.checked)}
/>
<span>Bei Listenwahl automatisch abgleichen</span>
</label>
<button
type="button"
className={styles.retryButton}
style={{ marginTop: 4 }}
onClick={() => runFormSyncFromList(true)}
disabled={listFieldsLoading}
>
Jetzt Formular &amp; Referenzen setzen
</button>
{!dataFlow ||
!findClosestUpstreamFormNode(
dataFlow.currentNodeId,
dataFlow.nodes,
dataFlow.connections
) ? (
<p style={{ fontSize: '0.8rem', color: '#a60', marginTop: 8, marginBottom: 0 }}>
Kein Formular-Knoten stromaufwärts verbunden zuerst ein Formular vor diesen Node ziehen und
verbinden.
</p>
) : null}
</div>
) : null}
{teamIdParam && listIdParam ? (
<div style={{ marginTop: 14 }}>
<label style={{ fontWeight: 600, display: 'block', marginBottom: 8 }}>
Standardfelder (wie in ClickUp)
</label>
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #666)', marginBottom: 10 }}>
Status, Priorität, Fälligkeit, Zuweisungen und Zeitschätzung dieselben Spalten wie in der
Listenansicht (nicht die benutzerdefinierten Felder darunter).
</p>
<div className={styles.dynamicValueField}>
<label>Status</label>
{listStatusesLoading ? (
<span style={{ fontSize: '0.85rem', color: '#666' }}>Status wird geladen</span>
) : (
<select
value={(params.taskStatus as string) ?? ''}
onChange={(e) => updateParam('taskStatus', e.target.value)}
>
<option value=""> Standard (ClickUp) </option>
{listStatuses.map((s) => (
<option key={s.status} value={s.status}>
{s.status}
</option>
))}
</select>
)}
</div>
<div className={styles.dynamicValueField} style={{ marginTop: 8 }}>
<label>Priorität</label>
<select
value={(params.taskPriority as string) ?? ''}
onChange={(e) => updateParam('taskPriority', e.target.value)}
>
<option value=""> keine </option>
<option value="1">1 Dringend</option>
<option value="2">2 Hoch</option>
<option value="3">3 Normal</option>
<option value="4">4 Niedrig</option>
</select>
</div>
<div style={{ marginTop: 8 }}>
<ClickUpTaskDueDateRow
value={params.taskDueDateMs}
onChange={(v) => updateParam('taskDueDateMs', v)}
/>
</div>
<div className={styles.dynamicValueField} style={{ marginTop: 8 }}>
<label>Zugewiesene</label>
{teamMembersLoading ? (
<span style={{ fontSize: '0.85rem', color: '#666' }}>Mitglieder werden geladen</span>
) : teamMembers.length === 0 ? (
<span style={{ fontSize: '0.85rem', color: '#666' }}>Keine Mitglieder geladen.</span>
) : (
<div
style={{
maxHeight: 160,
overflowY: 'auto',
border: '1px solid var(--border-color, #e0e0e0)',
borderRadius: 4,
padding: 8,
}}
>
{teamMembers.map((m) => (
<label
key={m.id}
style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}
>
<input
type="checkbox"
checked={taskAssigneeIds.includes(m.id)}
onChange={() => {
const set = new Set(taskAssigneeIds);
if (set.has(m.id)) set.delete(m.id);
else set.add(m.id);
updateParam('taskAssigneeIds', [...set]);
}}
/>
<span>{m.username}</span>
</label>
))}
</div>
)}
</div>
<div style={{ marginTop: 8 }}>
<ClickUpTimeEstimateHoursRow
value={timeEstimateHoursValue}
onChange={(v) => {
updateParam('taskTimeEstimateHours', v);
updateParam('taskTimeEstimateMs', createValue(''));
}}
/>
</div>
</div>
) : null}
{listIdParam ? (
<div style={{ marginTop: 12 }}>
<label style={{ fontWeight: 600 }}>Felder der Liste</label>
{listFieldsLoading ? (
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #666)' }}>Felder werden geladen</p>
) : listFields.length === 0 ? (
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #666)' }}>
Keine benutzerdefinierten Felder oder keine Berechtigung.
</p>
) : (
listFields.map((f) => {
const id = String(f.id ?? '');
if (!id) return null;
return (
<ClickUpCustomFieldRow
key={id}
field={f}
value={customFieldValues[id]}
onChange={(v) => setCustomField(id, v)}
connectionId={connectionId}
request={request}
parentListId={listIdParam}
/>
);
})
)}
</div>
) : null}
<details style={{ marginTop: 12 }}>
<summary style={{ cursor: 'pointer', fontSize: '0.875rem', color: 'var(--text-secondary, #555)' }}>
Erweitert (JSON)
</summary>
<div style={{ marginTop: 8 }}>
<label>Zusätzliche Felder (JSON)</label>
<textarea
value={(params.taskFields as string) ?? ''}
onChange={(e) => updateParam('taskFields', e.target.value)}
rows={3}
placeholder='{"priority": "3"}'
/>
</div>
</details>
</>
)}
{nodeType === 'clickup.listTasks' && (
<>
<div>
<label>List path</label>
<input
value={path}
onChange={(e) => updateParam('path', e.target.value)}
placeholder="/team/{teamId}/list/{listId}"
/>
</div>
<div>
<label>
<input
type="checkbox"
checked={Boolean(params.includeClosed)}
onChange={(e) => updateParam('includeClosed', e.target.checked)}
/>{' '}
Include closed tasks
</label>
</div>
</>
)}
{(nodeType === 'clickup.getTask' ||
nodeType === 'clickup.updateTask' ||
nodeType === 'clickup.uploadAttachment') && (
<>
{nodeType === 'clickup.updateTask' &&
!isRef(params.taskId) &&
connectionId &&
listIdParam &&
request ? (
<ClickUpTaskFromListDropdown
connectionId={connectionId}
listId={listIdParam}
request={request}
taskId={params.taskId}
onSetTaskId={(v) => updateParam('taskId', v)}
/>
) : null}
<HybridStaticRefField
label="Task-ID"
value={params.taskId}
onChange={(v) => updateParam('taskId', v)}
placeholder="Referenz: voriger Knoten „Aufgabe erstellen“ → taskId"
pathPickMode={nodeType === 'clickup.updateTask' ? 'clickup_task_id' : 'default'}
/>
<div>
<label>Path (optional)</label>
<input
value={path}
onChange={(e) => updateParam('path', e.target.value)}
placeholder=".../task/{taskId}"
/>
</div>
</>
)}
{nodeType === 'clickup.updateTask' && (
<div
id="clickup-update-task-list-anchor"
style={{
marginTop: 16,
padding: 12,
borderRadius: 8,
border: '1px solid var(--border-color, #e0e0e0)',
background: 'var(--bg-secondary, #f8f9fa)',
}}
>
<label style={{ fontWeight: 600, display: 'block', marginBottom: 6 }}>
Liste für Status &amp; Felder
</label>
<p
style={{
fontSize: '0.8rem',
color: 'var(--text-secondary, #666)',
marginTop: 0,
marginBottom: 10,
}}
>
Ohne diese Auswahl bleiben die Status leer, es sei denn, die Task-ID ist gesetzt und die
Vorschau liefert eine gültige ClickUp-Aufgaben-ID dann wird die Liste aus der Aufgabe
ermittelt.
</p>
<div>
<label>Workspace</label>
<select
value={teamIdParam}
onChange={(e) => {
const v = e.target.value;
updateParam('teamId', v);
updateParam('listId', '');
updateParam('path', '');
}}
disabled={searchTeamsLoading || !connectionId}
>
<option value="">
{searchTeamsLoading ? 'Workspaces werden geladen…' : 'Workspace wählen…'}
</option>
{searchTeams.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
</div>
<div style={{ marginTop: 8 }}>
<label>Liste</label>
<select
value={listIdParam}
onChange={(e) => {
const lid = e.target.value;
updateParam('listId', lid);
if (teamIdParam && lid) {
updateParam('path', `/team/${teamIdParam}/list/${lid}`);
} else {
updateParam('path', '');
}
}}
disabled={!teamIdParam || searchListsLoading}
>
<option value="">{searchListsLoading ? 'Listen werden geladen…' : 'Liste wählen…'}</option>
{searchLists.map((L) => (
<option key={L.id} value={L.id}>
{L.name}
</option>
))}
</select>
</div>
</div>
)}
{nodeType === 'clickup.updateTask' && request && (
<>
<ClickUpUpdateTaskEntriesEditor
rows={normalizeTaskUpdateEntries(params.taskUpdateEntries)}
onChangeRows={(next) => updateParam('taskUpdateEntries', next)}
connectionId={connectionId}
listId={effectiveListIdForStatuses}
pathForListId={path}
request={request}
taskIdParam={params.taskId}
teamMembers={teamMembers}
teamMembersLoading={teamMembersLoading}
parentListStatuses={listStatuses}
parentListStatusesLoading={listStatusesLoading}
/>
<details style={{ marginTop: 12 }}>
<summary style={{ cursor: 'pointer', fontSize: '0.875rem', color: 'var(--text-secondary, #555)' }}>
Erweitert: JSON (optional, wird mit den Zeilen zusammengeführt)
</summary>
<div style={{ marginTop: 8 }}>
<label>taskUpdate (JSON)</label>
<textarea
value={(params.taskUpdate as string) ?? ''}
onChange={(e) => updateParam('taskUpdate', e.target.value)}
rows={5}
placeholder='{"name": "…"} — Basis; Zeilen darüber setzen/überschreiben Felder'
/>
</div>
</details>
</>
)}
{nodeType === 'clickup.uploadAttachment' && (
<div>
<label>File name (optional)</label>
<input
value={(params.fileName as string) ?? ''}
onChange={(e) => updateParam('fileName', e.target.value)}
placeholder="report.pdf"
/>
</div>
)}
{showBrowse && (
<details
open={browseOpen}
onToggle={(e) => setBrowseOpen((e.target as HTMLDetailsElement).open)}
style={browseDetailsStyle}
>
<summary style={browseSummaryStyle}>{browseTitle(nodeType)}</summary>
<div style={browseBodyStyle}>
<SharepointBrowseTree
rootPath="/"
onLoadChildren={loadChildren}
onSelectFile={isTaskPicker(nodeType) ? selectPath : () => {}}
onSelectFolder={isListPicker(nodeType) ? selectPath : undefined}
foldersOnly={isListPicker(nodeType)}
selectedPath={path || null}
/>
</div>
</details>
)}
</>
);
};