Merge pull request 'int' (#5) from int into main
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 50s
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 50s
Reviewed-on: #5
This commit is contained in:
commit
b21fa78665
9 changed files with 534 additions and 8 deletions
17
src/api/workflowApi.connectionPath.test.ts
Normal file
17
src/api/workflowApi.connectionPath.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -943,13 +943,18 @@ export interface ConnectionService {
|
||||||
icon: string;
|
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(
|
export async function fetchConnectionServices(
|
||||||
request: ApiRequestFunction,
|
request: ApiRequestFunction,
|
||||||
instanceId: string,
|
instanceId: string,
|
||||||
connectionId: string
|
connectionId: string
|
||||||
): Promise<ConnectionService[]> {
|
): Promise<ConnectionService[]> {
|
||||||
const data = await request({
|
const data = await request({
|
||||||
url: `/api/workflows/${instanceId}/connections/${connectionId}/services`,
|
url: `/api/workflows/${instanceId}/connections/${_encodedConnectionId(connectionId)}/services`,
|
||||||
method: 'get',
|
method: 'get',
|
||||||
});
|
});
|
||||||
return data?.services ?? [];
|
return data?.services ?? [];
|
||||||
|
|
@ -972,7 +977,7 @@ export async function fetchBrowse(
|
||||||
path = '/'
|
path = '/'
|
||||||
): Promise<{ items: BrowseEntry[]; path: string; service: string }> {
|
): Promise<{ items: BrowseEntry[]; path: string; service: string }> {
|
||||||
const data = await request({
|
const data = await request({
|
||||||
url: `/api/workflows/${instanceId}/connections/${connectionId}/browse`,
|
url: `/api/workflows/${instanceId}/connections/${_encodedConnectionId(connectionId)}/browse`,
|
||||||
method: 'get',
|
method: 'get',
|
||||||
params: { service, path },
|
params: { service, path },
|
||||||
});
|
});
|
||||||
|
|
@ -986,7 +991,7 @@ export async function fetchClickupTask(
|
||||||
taskId: string
|
taskId: string
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
const data = await request({
|
const data = await request({
|
||||||
url: `/api/clickup/${connectionId}/tasks/${encodeURIComponent(taskId)}`,
|
url: `/api/clickup/${_encodedConnectionId(connectionId)}/tasks/${encodeURIComponent(taskId)}`,
|
||||||
method: 'get',
|
method: 'get',
|
||||||
});
|
});
|
||||||
return data && typeof data === 'object' ? (data as Record<string, unknown>) : {};
|
return data && typeof data === 'object' ? (data as Record<string, unknown>) : {};
|
||||||
|
|
@ -999,7 +1004,7 @@ export async function fetchClickupList(
|
||||||
listId: string
|
listId: string
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
const data = await request({
|
const data = await request({
|
||||||
url: `/api/clickup/${connectionId}/lists/${listId}`,
|
url: `/api/clickup/${_encodedConnectionId(connectionId)}/lists/${encodeURIComponent(listId)}`,
|
||||||
method: 'get',
|
method: 'get',
|
||||||
});
|
});
|
||||||
return data && typeof data === 'object' ? (data as Record<string, unknown>) : {};
|
return data && typeof data === 'object' ? (data as Record<string, unknown>) : {};
|
||||||
|
|
@ -1012,7 +1017,7 @@ export async function fetchClickupTeam(
|
||||||
teamId: string
|
teamId: string
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
const data = await request({
|
const data = await request({
|
||||||
url: `/api/clickup/${connectionId}/teams/${teamId}`,
|
url: `/api/clickup/${_encodedConnectionId(connectionId)}/teams/${encodeURIComponent(teamId)}`,
|
||||||
method: 'get',
|
method: 'get',
|
||||||
});
|
});
|
||||||
return data && typeof data === 'object' ? (data as Record<string, unknown>) : {};
|
return data && typeof data === 'object' ? (data as Record<string, unknown>) : {};
|
||||||
|
|
@ -1025,7 +1030,7 @@ export async function fetchClickupListFields(
|
||||||
listId: string
|
listId: string
|
||||||
): Promise<{ fields?: unknown[] } & Record<string, unknown>> {
|
): Promise<{ fields?: unknown[] } & Record<string, unknown>> {
|
||||||
const data = await request({
|
const data = await request({
|
||||||
url: `/api/clickup/${connectionId}/lists/${listId}/fields`,
|
url: `/api/clickup/${_encodedConnectionId(connectionId)}/lists/${encodeURIComponent(listId)}/fields`,
|
||||||
method: 'get',
|
method: 'get',
|
||||||
});
|
});
|
||||||
return (data && typeof data === 'object' ? data : {}) as { fields?: unknown[] } & Record<string, unknown>;
|
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>
|
{ tasks?: ClickupListTaskItem[]; last_page?: boolean } & Record<string, unknown>
|
||||||
> {
|
> {
|
||||||
const data = await request({
|
const data = await request({
|
||||||
url: `/api/clickup/${connectionId}/lists/${listId}/tasks`,
|
url: `/api/clickup/${_encodedConnectionId(connectionId)}/lists/${encodeURIComponent(listId)}/tasks`,
|
||||||
method: 'get',
|
method: 'get',
|
||||||
params: {
|
params: {
|
||||||
page: options?.page ?? 0,
|
page: options?.page ?? 0,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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';
|
||||||
|
}
|
||||||
|
|
@ -53,6 +53,7 @@ import { ContextBuilderRenderer } from './ContextBuilderRenderer';
|
||||||
import { ContextAssignmentsEditor } from './ContextAssignmentsEditor';
|
import { ContextAssignmentsEditor } from './ContextAssignmentsEditor';
|
||||||
import { FeatureInstancePicker } from './FeatureInstancePicker';
|
import { FeatureInstancePicker } from './FeatureInstancePicker';
|
||||||
import { UserFileFolderPicker } from './UserFileFolderPicker';
|
import { UserFileFolderPicker } from './UserFileFolderPicker';
|
||||||
|
import { ClickUpListPicker } from './ClickUpListPicker';
|
||||||
import { ConditionEditor } from './ConditionEditor';
|
import { ConditionEditor } from './ConditionEditor';
|
||||||
import { CaseListEditor } from './CaseListEditor';
|
import { CaseListEditor } from './CaseListEditor';
|
||||||
import { TemplateTextareaRenderer } from './TemplateTextareaRenderer';
|
import { TemplateTextareaRenderer } from './TemplateTextareaRenderer';
|
||||||
|
|
@ -1068,7 +1069,7 @@ export const FRONTEND_TYPE_RENDERERS: Record<string, FieldRendererComponent> = {
|
||||||
sharepointFolder: SharepointPathPicker,
|
sharepointFolder: SharepointPathPicker,
|
||||||
sharepointFile: SharepointPathPicker,
|
sharepointFile: SharepointPathPicker,
|
||||||
userFileFolder: UserFileFolderPicker,
|
userFileFolder: UserFileFolderPicker,
|
||||||
clickupList: FolderPicker,
|
clickupList: ClickUpListPicker,
|
||||||
clickupTask: FolderPicker,
|
clickupTask: FolderPicker,
|
||||||
caseList: CaseListEditor,
|
caseList: CaseListEditor,
|
||||||
fieldBuilder: FieldBuilderEditor,
|
fieldBuilder: FieldBuilderEditor,
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ export function formatApiError(error: any, defaultMessage: string): string {
|
||||||
// Handle other error formats
|
// Handle other error formats
|
||||||
if (typeof data?.detail === 'string') return data.detail;
|
if (typeof data?.detail === 'string') return data.detail;
|
||||||
if (typeof data?.message === 'string') return data.message;
|
if (typeof data?.message === 'string') return data.message;
|
||||||
|
if (typeof data?.error === 'string') return data.error;
|
||||||
if (typeof data === 'string') return data;
|
if (typeof data === 'string') return data;
|
||||||
|
|
||||||
return defaultMessage;
|
return defaultMessage;
|
||||||
|
|
|
||||||
|
|
@ -791,6 +791,9 @@ export function useConnections() {
|
||||||
knowledgePreferences?: import('../api/connectionApi').KnowledgePreferences | null,
|
knowledgePreferences?: import('../api/connectionApi').KnowledgePreferences | null,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
if (isConnecting) return;
|
if (isConnecting) return;
|
||||||
|
if (type === 'infomaniak') {
|
||||||
|
throw new Error('Infomaniak uses PAT flow – use createInfomaniakConnection + submitInfomaniakToken instead.');
|
||||||
|
}
|
||||||
setIsConnecting(true);
|
setIsConnecting(true);
|
||||||
try {
|
try {
|
||||||
const newConnection = await createConnection({
|
const newConnection = await createConnection({
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,8 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
connectWithPopup,
|
connectWithPopup,
|
||||||
refreshMicrosoftToken,
|
refreshMicrosoftToken,
|
||||||
refreshGoogleToken,
|
refreshGoogleToken,
|
||||||
|
createInfomaniakConnection,
|
||||||
|
submitInfomaniakToken,
|
||||||
isConnecting,
|
isConnecting,
|
||||||
} = useConnections();
|
} = 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 handleMsftAdminConsent = () => {
|
||||||
const url = `${getApiBaseUrl()}/api/msft/adminconsent`;
|
const url = `${getApiBaseUrl()}/api/msft/adminconsent`;
|
||||||
window.open(url, 'msft-admin-consent', 'width=560,height=720,scrollbars=yes,resizable=yes');
|
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}
|
open={wizardOpen}
|
||||||
onClose={() => setWizardOpen(false)}
|
onClose={() => setWizardOpen(false)}
|
||||||
onConnect={handleWizardConnect}
|
onConnect={handleWizardConnect}
|
||||||
|
onInfomaniakConnect={handleInfomaniakConnect}
|
||||||
onMsftAdminConsent={handleMsftAdminConsent}
|
onMsftAdminConsent={handleMsftAdminConsent}
|
||||||
isConnecting={isConnecting}
|
isConnecting={isConnecting}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue