2221 lines
72 KiB
TypeScript
2221 lines
72 KiB
TypeScript
/**
|
||
* 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 (1–4)' },
|
||
{ 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 & 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 & 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>
|
||
)}
|
||
</>
|
||
);
|
||
};
|