Compare commits
No commits in common. "b21fa78665d1cb027b896de8961b8d19a468488b" and "30db1b8316b04542e3073a9c6ab843ffa521417c" have entirely different histories.
b21fa78665
...
30db1b8316
9 changed files with 8 additions and 534 deletions
|
|
@ -1,17 +0,0 @@
|
||||||
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,18 +943,13 @@ 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/${_encodedConnectionId(connectionId)}/services`,
|
url: `/api/workflows/${instanceId}/connections/${connectionId}/services`,
|
||||||
method: 'get',
|
method: 'get',
|
||||||
});
|
});
|
||||||
return data?.services ?? [];
|
return data?.services ?? [];
|
||||||
|
|
@ -977,7 +972,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/${_encodedConnectionId(connectionId)}/browse`,
|
url: `/api/workflows/${instanceId}/connections/${connectionId}/browse`,
|
||||||
method: 'get',
|
method: 'get',
|
||||||
params: { service, path },
|
params: { service, path },
|
||||||
});
|
});
|
||||||
|
|
@ -991,7 +986,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/${_encodedConnectionId(connectionId)}/tasks/${encodeURIComponent(taskId)}`,
|
url: `/api/clickup/${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>) : {};
|
||||||
|
|
@ -1004,7 +999,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/${_encodedConnectionId(connectionId)}/lists/${encodeURIComponent(listId)}`,
|
url: `/api/clickup/${connectionId}/lists/${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>) : {};
|
||||||
|
|
@ -1017,7 +1012,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/${_encodedConnectionId(connectionId)}/teams/${encodeURIComponent(teamId)}`,
|
url: `/api/clickup/${connectionId}/teams/${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>) : {};
|
||||||
|
|
@ -1030,7 +1025,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/${_encodedConnectionId(connectionId)}/lists/${encodeURIComponent(listId)}/fields`,
|
url: `/api/clickup/${connectionId}/lists/${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>;
|
||||||
|
|
@ -1051,7 +1046,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/${_encodedConnectionId(connectionId)}/lists/${encodeURIComponent(listId)}/tasks`,
|
url: `/api/clickup/${connectionId}/lists/${listId}/tasks`,
|
||||||
method: 'get',
|
method: 'get',
|
||||||
params: {
|
params: {
|
||||||
page: options?.page ?? 0,
|
page: options?.page ?? 0,
|
||||||
|
|
|
||||||
|
|
@ -1,396 +0,0 @@
|
||||||
/**
|
|
||||||
* 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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
/** 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,7 +53,6 @@ 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';
|
||||||
|
|
@ -1069,7 +1068,7 @@ export const FRONTEND_TYPE_RENDERERS: Record<string, FieldRendererComponent> = {
|
||||||
sharepointFolder: SharepointPathPicker,
|
sharepointFolder: SharepointPathPicker,
|
||||||
sharepointFile: SharepointPathPicker,
|
sharepointFile: SharepointPathPicker,
|
||||||
userFileFolder: UserFileFolderPicker,
|
userFileFolder: UserFileFolderPicker,
|
||||||
clickupList: ClickUpListPicker,
|
clickupList: FolderPicker,
|
||||||
clickupTask: FolderPicker,
|
clickupTask: FolderPicker,
|
||||||
caseList: CaseListEditor,
|
caseList: CaseListEditor,
|
||||||
fieldBuilder: FieldBuilderEditor,
|
fieldBuilder: FieldBuilderEditor,
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,6 @@ 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,9 +791,6 @@ 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,8 +46,6 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
connectWithPopup,
|
connectWithPopup,
|
||||||
refreshMicrosoftToken,
|
refreshMicrosoftToken,
|
||||||
refreshGoogleToken,
|
refreshGoogleToken,
|
||||||
createInfomaniakConnection,
|
|
||||||
submitInfomaniakToken,
|
|
||||||
isConnecting,
|
isConnecting,
|
||||||
} = useConnections();
|
} = useConnections();
|
||||||
|
|
||||||
|
|
@ -236,17 +234,6 @@ 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');
|
||||||
|
|
@ -482,7 +469,6 @@ 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