From b36b303def5ffd465020c6aa31a4dc2b6661b86e Mon Sep 17 00:00:00 2001 From: Stephan Schellworth Date: Thu, 4 Jun 2026 08:53:49 +0200 Subject: [PATCH 1/2] feat(flow-editor): add ClickUp hierarchical list picker for clickupList fields Replaces the text-only FolderPicker stub with browse-based list selection, auto-patches teamId on searchTasks, and adds path utility tests. Co-authored-by: Cursor --- .../ClickUpListPicker.tsx | 396 ++++++++++++++++++ .../clickupPathUtils.test.ts | 28 ++ .../frontendTypeRenderers/clickupPathUtils.ts | 61 +++ .../nodes/frontendTypeRenderers/index.tsx | 3 +- 4 files changed, 487 insertions(+), 1 deletion(-) create mode 100644 src/components/FlowEditor/nodes/frontendTypeRenderers/ClickUpListPicker.tsx create mode 100644 src/components/FlowEditor/nodes/frontendTypeRenderers/clickupPathUtils.test.ts create mode 100644 src/components/FlowEditor/nodes/frontendTypeRenderers/clickupPathUtils.ts diff --git a/src/components/FlowEditor/nodes/frontendTypeRenderers/ClickUpListPicker.tsx b/src/components/FlowEditor/nodes/frontendTypeRenderers/ClickUpListPicker.tsx new file mode 100644 index 0000000..15a2ccf --- /dev/null +++ b/src/components/FlowEditor/nodes/frontendTypeRenderers/ClickUpListPicker.tsx @@ -0,0 +1,396 @@ +/** + * clickupList — hierarchical ClickUp list picker via connector browse API. + */ + +import React, { useCallback, useEffect, useState } from 'react'; +import { useLanguage } from '../../../../providers/language/LanguageContext'; +import { + fetchBrowse, + fetchClickupList, + type BrowseEntry, +} from '../../../../api/workflowApi'; +import type { FieldRendererProps } from './index'; +import { + clickupBrowseParentPath, + formatListPickerValue, + isClickupContainerEntry, + isClickupListEntry, + parseClickupListPath, + resolveListPathFromValue, +} from './clickupPathUtils'; + +const CLICKUP_PURPLE = '#7B68EE'; + +const glassPanel: React.CSSProperties = { + marginTop: 6, + borderRadius: 10, + border: '1px solid rgba(123, 104, 238, 0.35)', + background: 'rgba(255, 255, 255, 0.72)', + backdropFilter: 'blur(10px)', + WebkitBackdropFilter: 'blur(10px)', + boxShadow: + '0 4px 24px rgba(123, 104, 238, 0.12), 0 0 0 1px rgba(255, 255, 255, 0.5) inset', + padding: 8, +}; + +const glassTrigger: React.CSSProperties = { + display: 'flex', + width: '100%', + alignItems: 'stretch', + borderRadius: 8, + border: '1px solid rgba(123, 104, 238, 0.4)', + background: 'linear-gradient(135deg, rgba(255,255,255,0.9) 0%, rgba(123,104,238,0.08) 100%)', + boxShadow: '0 0 12px rgba(123, 104, 238, 0.15)', + overflow: 'hidden', +}; + +export const ClickUpListPicker: React.FC = ({ + param, + value, + onChange, + allParams, + instanceId, + request, + onPatchParams, + nodeType, +}) => { + const { t } = useLanguage(); + const dependsOn = (param.frontendOptions?.dependsOn as string | undefined) || 'connectionReference'; + const connectionReference = (allParams?.[dependsOn] as string | undefined) || ''; + const hasConnection = !!connectionReference && typeof connectionReference === 'string'; + + const [panelOpen, setPanelOpen] = useState(false); + const [browsePath, setBrowsePath] = useState('/'); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [pickedLabel, setPickedLabel] = useState(null); + + const strVal = typeof value === 'string' ? value : value != null ? String(value) : ''; + + const loadBrowse = useCallback( + async (path: string) => { + if (!request || !instanceId || !connectionReference) return; + setLoading(true); + setError(null); + try { + const res = await fetchBrowse(request, instanceId, connectionReference, 'clickup', path); + setItems(res.items); + setBrowsePath(res.path || path); + } catch (err: unknown) { + setItems([]); + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, + [request, instanceId, connectionReference], + ); + + useEffect(() => { + if (!panelOpen || !hasConnection) return; + void loadBrowse(browsePath); + }, [panelOpen, hasConnection, browsePath, loadBrowse]); + + useEffect(() => { + if (!strVal) { + setPickedLabel(null); + return; + } + const pathFromVal = resolveListPathFromValue(strVal, param.name); + if (pathFromVal) { + const parsed = parseClickupListPath(pathFromVal); + if (parsed.listId && request && connectionReference) { + let cancelled = false; + fetchClickupList(request, connectionReference, parsed.listId) + .then((data) => { + if (cancelled) return; + const name = typeof data.name === 'string' ? data.name : null; + setPickedLabel(name || parsed.listId || strVal); + }) + .catch(() => { + if (!cancelled) setPickedLabel(parsed.listId || strVal); + }); + return () => { + cancelled = true; + }; + } + setPickedLabel(parsed.listId || strVal); + return; + } + if (param.name === 'listId' && strVal && request && connectionReference) { + let cancelled = false; + fetchClickupList(request, connectionReference, strVal) + .then((data) => { + if (cancelled) return; + setPickedLabel(typeof data.name === 'string' ? data.name : strVal); + }) + .catch(() => { + if (!cancelled) setPickedLabel(strVal); + }); + return () => { + cancelled = true; + }; + } + setPickedLabel(strVal); + }, [strVal, param.name, request, connectionReference]); + + const shouldPatchTeamId = + nodeType === 'clickup.searchTasks' || Object.prototype.hasOwnProperty.call(allParams ?? {}, 'teamId'); + + const selectList = useCallback( + (entry: BrowseEntry) => { + const listPath = entry.path; + const stored = formatListPickerValue(listPath, param.name); + const { teamId, listId } = parseClickupListPath(listPath); + + if (shouldPatchTeamId && onPatchParams && teamId) { + const patch: Record = { [param.name]: stored }; + patch.teamId = teamId; + onPatchParams(patch); + } else { + onChange(stored); + } + + setPickedLabel(entry.name || listId || stored); + setPanelOpen(false); + }, + [param.name, shouldPatchTeamId, onPatchParams, onChange], + ); + + const navigateInto = useCallback((entry: BrowseEntry) => { + if (!isClickupContainerEntry(entry.metadata, entry.isFolder)) return; + setBrowsePath(entry.path); + }, []); + + const goUp = useCallback(() => { + setBrowsePath((p) => clickupBrowseParentPath(p)); + }, []); + + const clearSelection = useCallback(() => { + if (shouldPatchTeamId && onPatchParams) { + const patch: Record = { [param.name]: '' }; + if (nodeType === 'clickup.searchTasks') { + patch.teamId = ''; + } + onPatchParams(patch); + } else { + onChange(''); + } + setPickedLabel(null); + }, [shouldPatchTeamId, onPatchParams, onChange, param.name, nodeType]); + + const triggerLabel = strVal + ? pickedLabel ?? '…' + : t('ClickUp-Liste wählen'); + + const breadcrumb = + browsePath === '/' + ? t('Workspaces') + : browsePath.replace(/^\/team\//, '').replace(/\//g, ' › '); + + return ( +
+ + {!request || !instanceId ? ( +
+ {t('Listen-Browser nicht verfügbar (keine API-Anbindung).')} +
+ ) : ( + <> +
+ + {strVal ? ( + + ) : null} +
+ + {panelOpen && hasConnection && ( +
+ {error && ( +
{error}
+ )} +
+ {browsePath !== '/' && ( + + )} + + + {breadcrumb} + +
+
+ {loading && ( +
{t('Lade')}
+ )} + {!loading && items.length === 0 && ( +
+ {t('Keine Einträge')} +
+ )} + {!loading && + items.map((item) => { + const isList = isClickupListEntry(item.metadata); + const canNavigate = isClickupContainerEntry(item.metadata, item.isFolder); + return ( +
+ { + if (isList) selectList(item); + else if (canNavigate) navigateInto(item); + }} + onKeyDown={(e) => { + if (e.key !== 'Enter' && e.key !== ' ') return; + e.preventDefault(); + if (isList) selectList(item); + else if (canNavigate) navigateInto(item); + }} + style={{ + flex: 1, + cursor: isList || canNavigate ? 'pointer' : 'default', + userSelect: 'none', + }} + title={isList ? t('Liste wählen') : canNavigate ? t('Öffnen') : undefined} + > + {isList ? '📋' : canNavigate ? '📁' : '·'} {item.name} + + {isList && ( + + )} +
+ ); + })} +
+
+ )} + + )} +
+ ); +}; diff --git a/src/components/FlowEditor/nodes/frontendTypeRenderers/clickupPathUtils.test.ts b/src/components/FlowEditor/nodes/frontendTypeRenderers/clickupPathUtils.test.ts new file mode 100644 index 0000000..e67613a --- /dev/null +++ b/src/components/FlowEditor/nodes/frontendTypeRenderers/clickupPathUtils.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { + clickupBrowseParentPath, + formatListPickerValue, + parseClickupListPath, +} from './clickupPathUtils'; + +describe('clickupPathUtils', () => { + it('parseClickupListPath extracts team and list ids', () => { + expect(parseClickupListPath('/team/abc/list/xyz')).toEqual({ + teamId: 'abc', + listId: 'xyz', + }); + expect(parseClickupListPath('')).toEqual({}); + }); + + it('formatListPickerValue stores path or listId by param name', () => { + const path = '/team/abc/list/xyz'; + expect(formatListPickerValue(path, 'pathQuery')).toBe(path); + expect(formatListPickerValue(path, 'listId')).toBe('xyz'); + }); + + it('clickupBrowseParentPath walks up hierarchy', () => { + expect(clickupBrowseParentPath('/')).toBe('/'); + expect(clickupBrowseParentPath('/team/t1')).toBe('/'); + expect(clickupBrowseParentPath('/team/t1/space/s1')).toBe('/team/t1'); + }); +}); diff --git a/src/components/FlowEditor/nodes/frontendTypeRenderers/clickupPathUtils.ts b/src/components/FlowEditor/nodes/frontendTypeRenderers/clickupPathUtils.ts new file mode 100644 index 0000000..3d9df92 --- /dev/null +++ b/src/components/FlowEditor/nodes/frontendTypeRenderers/clickupPathUtils.ts @@ -0,0 +1,61 @@ +/** Parse virtual ClickUp list paths: /team/{teamId}/list/{listId} */ + +const LIST_PATH_RE = /^\/team\/([^/]+)\/list\/([^/]+)$/; + +export function parseClickupListPath(path: string): { teamId?: string; listId?: string } { + const p = (path || '').trim(); + const m = p.match(LIST_PATH_RE); + if (!m) return {}; + return { teamId: m[1], listId: m[2] }; +} + +/** Store path for pathQuery; raw list id for listId param. */ +export function formatListPickerValue(listPath: string, paramName: string): string { + const { listId } = parseClickupListPath(listPath); + if (paramName === 'listId' && listId) return listId; + return listPath; +} + +/** Resolve list path from stored value (path or legacy raw id). */ +export function resolveListPathFromValue(value: string, paramName: string): string | null { + const v = (value || '').trim(); + if (!v) return null; + if (LIST_PATH_RE.test(v)) return v; + if (paramName === 'listId' && /^[a-zA-Z0-9_-]+$/.test(v)) { + return null; + } + return v; +} + +export function clickupBrowseParentPath(path: string): string { + const p = (path || '/').trim() || '/'; + if (p === '/') return '/'; + const folder = p.match(/^(\/team\/[^/]+\/space\/[^/]+)\/folder\/[^/]+$/); + if (folder) return folder[1]; + const space = p.match(/^(\/team\/[^/]+)\/space\/[^/]+$/); + if (space) return space[1]; + if (/^\/team\/[^/]+$/.test(p)) return '/'; + const parts = p.split('/').filter(Boolean); + if (parts.length <= 1) return '/'; + parts.pop(); + return `/${parts.join('/')}`; +} + +export function cuTypeFromEntry(metadata?: Record): string { + const t = metadata?.cuType; + return typeof t === 'string' ? t : ''; +} + +export function isClickupListEntry(metadata?: Record): boolean { + return cuTypeFromEntry(metadata) === 'list'; +} + +export function isClickupContainerEntry( + metadata: Record | undefined, + isFolder: boolean, +): boolean { + const cu = cuTypeFromEntry(metadata); + if (cu === 'list') return false; + if (cu === 'team' || cu === 'space' || cu === 'folder') return true; + return isFolder && cu !== 'task'; +} diff --git a/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx b/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx index a2f3071..a2d9193 100644 --- a/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx +++ b/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx @@ -53,6 +53,7 @@ import { ContextBuilderRenderer } from './ContextBuilderRenderer'; import { ContextAssignmentsEditor } from './ContextAssignmentsEditor'; import { FeatureInstancePicker } from './FeatureInstancePicker'; import { UserFileFolderPicker } from './UserFileFolderPicker'; +import { ClickUpListPicker } from './ClickUpListPicker'; import { ConditionEditor } from './ConditionEditor'; import { CaseListEditor } from './CaseListEditor'; import { TemplateTextareaRenderer } from './TemplateTextareaRenderer'; @@ -1068,7 +1069,7 @@ export const FRONTEND_TYPE_RENDERERS: Record = { sharepointFolder: SharepointPathPicker, sharepointFile: SharepointPathPicker, userFileFolder: UserFileFolderPicker, - clickupList: FolderPicker, + clickupList: ClickUpListPicker, clickupTask: FolderPicker, caseList: CaseListEditor, fieldBuilder: FieldBuilderEditor, From 059bbe956a303b56e60705589954f894f9e4b5b4 Mon Sep 17 00:00:00 2001 From: Stephan Schellworth Date: Thu, 4 Jun 2026 09:24:04 +0200 Subject: [PATCH 2/2] fix(flow-editor): encode connection id in browse and ClickUp API paths URL-encode connection references with spaces/colons so ClickUp list browse resolves correctly. Surface browse error field in formatApiError. Co-authored-by: Cursor --- src/api/workflowApi.connectionPath.test.ts | 17 +++++++++++++++++ src/api/workflowApi.ts | 19 ++++++++++++------- src/hooks/useApi.ts | 1 + 3 files changed, 30 insertions(+), 7 deletions(-) create mode 100644 src/api/workflowApi.connectionPath.test.ts diff --git a/src/api/workflowApi.connectionPath.test.ts b/src/api/workflowApi.connectionPath.test.ts new file mode 100644 index 0000000..90259cf --- /dev/null +++ b/src/api/workflowApi.connectionPath.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; + +/** Mirrors _encodedConnectionId in workflowApi.ts for browse/services URL paths. */ +function encodedConnectionId(connectionId: string): string { + return encodeURIComponent(connectionId); +} + +describe('connection path encoding for workflow browse', () => { + it('encodes spaces and colons in connection:clickup:username references', () => { + const ref = 'connection:clickup:Stephan Schellworth'; + const segment = encodedConnectionId(ref); + expect(segment).toBe('connection%3Aclickup%3AStephan%20Schellworth'); + const url = `/api/workflows/inst/connections/${segment}/browse`; + expect(url).not.toContain(' '); + expect(url).toContain('%20'); + }); +}); diff --git a/src/api/workflowApi.ts b/src/api/workflowApi.ts index c4144d4..8954c44 100644 --- a/src/api/workflowApi.ts +++ b/src/api/workflowApi.ts @@ -943,13 +943,18 @@ export interface ConnectionService { icon: string; } +/** Encode connection id/reference for URL path segments (may contain spaces/colons). */ +function _encodedConnectionId(connectionId: string): string { + return encodeURIComponent(connectionId); +} + export async function fetchConnectionServices( request: ApiRequestFunction, instanceId: string, connectionId: string ): Promise { const data = await request({ - url: `/api/workflows/${instanceId}/connections/${connectionId}/services`, + url: `/api/workflows/${instanceId}/connections/${_encodedConnectionId(connectionId)}/services`, method: 'get', }); return data?.services ?? []; @@ -972,7 +977,7 @@ export async function fetchBrowse( path = '/' ): Promise<{ items: BrowseEntry[]; path: string; service: string }> { const data = await request({ - url: `/api/workflows/${instanceId}/connections/${connectionId}/browse`, + url: `/api/workflows/${instanceId}/connections/${_encodedConnectionId(connectionId)}/browse`, method: 'get', params: { service, path }, }); @@ -986,7 +991,7 @@ export async function fetchClickupTask( taskId: string ): Promise> { const data = await request({ - url: `/api/clickup/${connectionId}/tasks/${encodeURIComponent(taskId)}`, + url: `/api/clickup/${_encodedConnectionId(connectionId)}/tasks/${encodeURIComponent(taskId)}`, method: 'get', }); return data && typeof data === 'object' ? (data as Record) : {}; @@ -999,7 +1004,7 @@ export async function fetchClickupList( listId: string ): Promise> { const data = await request({ - url: `/api/clickup/${connectionId}/lists/${listId}`, + url: `/api/clickup/${_encodedConnectionId(connectionId)}/lists/${encodeURIComponent(listId)}`, method: 'get', }); return data && typeof data === 'object' ? (data as Record) : {}; @@ -1012,7 +1017,7 @@ export async function fetchClickupTeam( teamId: string ): Promise> { const data = await request({ - url: `/api/clickup/${connectionId}/teams/${teamId}`, + url: `/api/clickup/${_encodedConnectionId(connectionId)}/teams/${encodeURIComponent(teamId)}`, method: 'get', }); return data && typeof data === 'object' ? (data as Record) : {}; @@ -1025,7 +1030,7 @@ export async function fetchClickupListFields( listId: string ): Promise<{ fields?: unknown[] } & Record> { const data = await request({ - url: `/api/clickup/${connectionId}/lists/${listId}/fields`, + url: `/api/clickup/${_encodedConnectionId(connectionId)}/lists/${encodeURIComponent(listId)}/fields`, method: 'get', }); return (data && typeof data === 'object' ? data : {}) as { fields?: unknown[] } & Record; @@ -1046,7 +1051,7 @@ export async function fetchClickupListTasks( { tasks?: ClickupListTaskItem[]; last_page?: boolean } & Record > { const data = await request({ - url: `/api/clickup/${connectionId}/lists/${listId}/tasks`, + url: `/api/clickup/${_encodedConnectionId(connectionId)}/lists/${encodeURIComponent(listId)}/tasks`, method: 'get', params: { page: options?.page ?? 0, diff --git a/src/hooks/useApi.ts b/src/hooks/useApi.ts index 724faac..0742005 100644 --- a/src/hooks/useApi.ts +++ b/src/hooks/useApi.ts @@ -36,6 +36,7 @@ export function formatApiError(error: any, defaultMessage: string): string { // Handle other error formats if (typeof data?.detail === 'string') return data.detail; if (typeof data?.message === 'string') return data.message; + if (typeof data?.error === 'string') return data.error; if (typeof data === 'string') return data; return defaultMessage;