Merge pull request 'int' (#5) from int into main
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 50s

Reviewed-on: #5
This commit is contained in:
p.motsch 2026-06-04 19:42:27 +00:00
commit b21fa78665
9 changed files with 534 additions and 8 deletions

View file

@ -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');
});
});

View file

@ -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<ConnectionService[]> {
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<Record<string, unknown>> {
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<string, unknown>) : {};
@ -999,7 +1004,7 @@ export async function fetchClickupList(
listId: string
): Promise<Record<string, unknown>> {
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<string, unknown>) : {};
@ -1012,7 +1017,7 @@ export async function fetchClickupTeam(
teamId: string
): Promise<Record<string, unknown>> {
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<string, unknown>) : {};
@ -1025,7 +1030,7 @@ export async function fetchClickupListFields(
listId: string
): Promise<{ fields?: unknown[] } & Record<string, unknown>> {
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<string, unknown>;
@ -1046,7 +1051,7 @@ export async function fetchClickupListTasks(
{ tasks?: ClickupListTaskItem[]; last_page?: boolean } & Record<string, unknown>
> {
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,

View file

@ -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<FieldRendererProps> = ({
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<BrowseEntry[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pickedLabel, setPickedLabel] = useState<string | null>(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<string, unknown> = { [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<string, unknown> = { [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 (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>
{param.description || param.name}
</label>
{!request || !instanceId ? (
<div style={{ fontSize: 11, color: '#888' }}>
{t('Listen-Browser nicht verfügbar (keine API-Anbindung).')}
</div>
) : (
<>
<div style={{ ...glassTrigger, opacity: hasConnection ? 1 : 0.55 }}>
<button
type="button"
disabled={!hasConnection}
onClick={() => {
if (!hasConnection) return;
setPanelOpen((o) => {
if (!o) setBrowsePath('/');
return !o;
});
}}
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
minWidth: 0,
padding: '8px 10px',
border: 'none',
background: 'transparent',
cursor: hasConnection ? 'pointer' : 'not-allowed',
fontSize: 12,
textAlign: 'left',
color: 'var(--text-primary, #334155)',
}}
>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
{hasConnection ? triggerLabel : t('Zuerst {field} wählen', { field: dependsOn })}
</span>
<span aria-hidden style={{ flexShrink: 0, fontSize: 10, opacity: 0.65 }}>
{panelOpen ? '▾' : '▸'}
</span>
</button>
{strVal ? (
<button
type="button"
title={t('Auswahl aufheben')}
aria-label={t('Auswahl aufheben')}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
clearSelection();
}}
style={{
flexShrink: 0,
width: 36,
border: 'none',
borderLeft: '1px solid rgba(123, 104, 238, 0.25)',
background: 'transparent',
cursor: 'pointer',
fontSize: 16,
lineHeight: 1,
color: 'var(--text-secondary, #64748b)',
padding: 0,
}}
>
×
</button>
) : null}
</div>
{panelOpen && hasConnection && (
<div style={glassPanel}>
{error && (
<div style={{ fontSize: 11, color: '#c00', marginBottom: 6 }}>{error}</div>
)}
<div
style={{
display: 'flex',
gap: 6,
alignItems: 'center',
marginBottom: 6,
flexWrap: 'wrap',
}}
>
{browsePath !== '/' && (
<button
type="button"
onClick={goUp}
style={{
padding: '3px 8px',
borderRadius: 6,
border: '1px solid rgba(123, 104, 238, 0.35)',
background: 'rgba(255,255,255,0.6)',
cursor: 'pointer',
fontSize: 11,
}}
>
{t('Hoch')}
</button>
)}
<button
type="button"
onClick={() => loadBrowse(browsePath)}
title={t('Neu laden')}
style={{
padding: '3px 8px',
borderRadius: 6,
border: '1px solid rgba(123, 104, 238, 0.35)',
background: 'rgba(255,255,255,0.6)',
cursor: 'pointer',
fontSize: 11,
}}
>
</button>
<span style={{ fontSize: 11, color: '#555', flex: 1, minWidth: 0 }}>
{breadcrumb}
</span>
</div>
<div
style={{
maxHeight: 220,
overflow: 'auto',
borderRadius: 6,
border: '1px solid rgba(123, 104, 238, 0.2)',
background: 'rgba(255, 255, 255, 0.85)',
}}
>
{loading && (
<div style={{ padding: 8, fontSize: 11, color: '#888' }}>{t('Lade')}</div>
)}
{!loading && items.length === 0 && (
<div style={{ padding: 8, fontSize: 11, color: '#888' }}>
{t('Keine Einträge')}
</div>
)}
{!loading &&
items.map((item) => {
const isList = isClickupListEntry(item.metadata);
const canNavigate = isClickupContainerEntry(item.metadata, item.isFolder);
return (
<div
key={item.path}
style={{
display: 'flex',
alignItems: 'center',
padding: '4px 8px',
borderBottom: '1px solid rgba(123, 104, 238, 0.08)',
fontSize: 12,
}}
>
<span
role="button"
tabIndex={0}
onClick={() => {
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}
</span>
{isList && (
<button
type="button"
onClick={() => selectList(item)}
style={{
padding: '2px 8px',
borderRadius: 6,
border: `1px solid ${CLICKUP_PURPLE}`,
background: CLICKUP_PURPLE,
color: '#fff',
cursor: 'pointer',
fontSize: 11,
boxShadow: '0 0 8px rgba(123, 104, 238, 0.45)',
}}
>
{t('Wählen')}
</button>
)}
</div>
);
})}
</div>
</div>
)}
</>
)}
</div>
);
};

View file

@ -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');
});
});

View file

@ -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, unknown>): string {
const t = metadata?.cuType;
return typeof t === 'string' ? t : '';
}
export function isClickupListEntry(metadata?: Record<string, unknown>): boolean {
return cuTypeFromEntry(metadata) === 'list';
}
export function isClickupContainerEntry(
metadata: Record<string, unknown> | 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';
}

View file

@ -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<string, FieldRendererComponent> = {
sharepointFolder: SharepointPathPicker,
sharepointFile: SharepointPathPicker,
userFileFolder: UserFileFolderPicker,
clickupList: FolderPicker,
clickupList: ClickUpListPicker,
clickupTask: FolderPicker,
caseList: CaseListEditor,
fieldBuilder: FieldBuilderEditor,

View file

@ -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;

View file

@ -791,6 +791,9 @@ export function useConnections() {
knowledgePreferences?: import('../api/connectionApi').KnowledgePreferences | null,
): Promise<void> => {
if (isConnecting) return;
if (type === 'infomaniak') {
throw new Error('Infomaniak uses PAT flow use createInfomaniakConnection + submitInfomaniakToken instead.');
}
setIsConnecting(true);
try {
const newConnection = await createConnection({

View file

@ -46,6 +46,8 @@ export const ConnectionsPage: React.FC = () => {
connectWithPopup,
refreshMicrosoftToken,
refreshGoogleToken,
createInfomaniakConnection,
submitInfomaniakToken,
isConnecting,
} = useConnections();
@ -234,6 +236,17 @@ export const ConnectionsPage: React.FC = () => {
}
};
const handleInfomaniakConnect = async (token: string, knowledgeEnabled: boolean) => {
try {
const newConn = await createInfomaniakConnection();
await submitInfomaniakToken(newConn.id, token);
refetch();
if (knowledgeEnabled) showSyncBanner('Infomaniak');
} catch (error) {
console.error('Error creating Infomaniak connection:', error);
}
};
const handleMsftAdminConsent = () => {
const url = `${getApiBaseUrl()}/api/msft/adminconsent`;
window.open(url, 'msft-admin-consent', 'width=560,height=720,scrollbars=yes,resizable=yes');
@ -469,6 +482,7 @@ export const ConnectionsPage: React.FC = () => {
open={wizardOpen}
onClose={() => setWizardOpen(false)}
onConnect={handleWizardConnect}
onInfomaniakConnect={handleInfomaniakConnect}
onMsftAdminConsent={handleMsftAdminConsent}
isConnecting={isConnecting}
/>