sharepoint nodes

This commit is contained in:
idittrich-valueon 2026-03-22 19:50:06 +01:00
parent af58d5a868
commit 896f7b5968
4 changed files with 479 additions and 93 deletions

View file

@ -6,7 +6,7 @@
import React, { useMemo } from 'react';
import { FaChevronDown, FaChevronRight } from 'react-icons/fa';
import type { NodeType, NodeTypeCategory } from '../../api/automation2Api';
import { CATEGORY_ORDER } from './constants';
import { CATEGORY_ORDER, HIDDEN_NODE_IDS } from './constants';
import { getLabel } from './utils';
import { NodeListItem } from './NodeListItem';
import styles from './Automation2FlowEditor.module.css';
@ -31,9 +31,10 @@ export const NodeSidebar: React.FC<NodeSidebarProps> = ({
onToggleCategory,
}) => {
const filteredNodeTypes = useMemo(() => {
if (!filter.trim()) return nodeTypes;
const visible = nodeTypes.filter((n) => !HIDDEN_NODE_IDS.has(n.id));
if (!filter.trim()) return visible;
const q = filter.toLowerCase();
return nodeTypes.filter(
return visible.filter(
(n) =>
n.id.toLowerCase().includes(q) ||
getLabel(n.label, language).toLowerCase().includes(q) ||

View file

@ -1,10 +1,12 @@
/**
* SharePoint node config - connection selector, path, search query.
* Uses SharepointBrowseTree (FolderTree-style) for file selection.
*/
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useCallback } from 'react';
import type { NodeConfigRendererProps } from './types';
import { fetchConnections, fetchBrowse, type UserConnection, type BrowseEntry } from '../../../api/automation2Api';
import { SharepointBrowseTree } from '../../FolderTree/SharepointBrowseTree';
export const SharePointNodeConfig: React.FC<NodeConfigRendererProps> = ({
params,
@ -14,43 +16,62 @@ export const SharePointNodeConfig: React.FC<NodeConfigRendererProps> = ({
nodeType = 'sharepoint.findFile',
}) => {
const [connections, setConnections] = useState<UserConnection[]>([]);
const [browseItems, setBrowseItems] = useState<BrowseEntry[]>([]);
const [currentPath, setCurrentPath] = useState('/');
const [loading, setLoading] = useState(false);
const [browseExpanded, setBrowseExpanded] = useState(false);
const [copySourceExpanded, setCopySourceExpanded] = useState(false);
const [copyDestExpanded, setCopyDestExpanded] = useState(false);
const [connectionsLoading, setConnectionsLoading] = useState(false);
const connectionId = (params.connectionId as string) ?? '';
const path = (params.path as string) ?? '/';
const pathParam = 'path';
const path = (params.path as string) ?? (params.filePath as string) ?? '';
useEffect(() => {
if (instanceId && request) {
setLoading(true);
setConnectionsLoading(true);
fetchConnections(request, instanceId)
.then(setConnections)
.catch(() => setConnections([]))
.finally(() => setLoading(false));
.finally(() => setConnectionsLoading(false));
}
}, [instanceId, request]);
useEffect(() => {
if (instanceId && request && connectionId) {
const service = 'sharepoint';
fetchBrowse(request, instanceId, connectionId, service, currentPath)
.then((r: { items: BrowseEntry[]; path: string; service: string }) => {
setBrowseItems(r.items);
setCurrentPath(r.path);
})
.catch(() => setBrowseItems([]));
} else {
setBrowseItems([]);
}
}, [instanceId, request, connectionId, currentPath]);
const loadChildren = useCallback(
async (pathToLoad: string): Promise<BrowseEntry[]> => {
if (!instanceId || !request || !connectionId) return [];
const r = await fetchBrowse(request, instanceId, connectionId, 'sharepoint', pathToLoad);
return r?.items ?? [];
},
[instanceId, request, connectionId]
);
const navigateTo = (entryPath: string) => setCurrentPath(entryPath);
const selectPath = (p: string) => updateParam('path', p);
const selectPath = useCallback(
(p: string) => {
updateParam(pathParam, p);
setBrowseExpanded(false);
},
[updateParam, pathParam]
);
const selectSourcePath = useCallback(
(p: string) => {
updateParam('sourcePath', p);
setCopySourceExpanded(false);
},
[updateParam]
);
const selectDestPath = useCallback(
(p: string) => {
updateParam('destPath', p);
setCopyDestExpanded(false);
},
[updateParam]
);
const needsPath = !['sharepoint.findFile'].includes(nodeType);
const needsSearch = nodeType === 'sharepoint.findFile';
const needsSiteId = ['sharepoint.uploadFile', 'sharepoint.downloadFile', 'sharepoint.copyFile'].includes(nodeType);
const needsSiteId = false;
const hasPathInput = ['sharepoint.readFile', 'sharepoint.uploadFile', 'sharepoint.downloadFile', 'sharepoint.copyFile'].includes(nodeType);
return (
<>
@ -58,13 +79,10 @@ export const SharePointNodeConfig: React.FC<NodeConfigRendererProps> = ({
<label>Connection</label>
<select
value={connectionId}
onChange={(e) => {
updateParam('connectionId', e.target.value);
setCurrentPath('/');
}}
disabled={loading}
onChange={(e) => updateParam('connectionId', e.target.value)}
disabled={connectionsLoading}
>
<option value="">{loading ? 'Loading...' : 'Select connection'}</option>
<option value="">{connectionsLoading ? 'Loading...' : 'Select connection'}</option>
{connections.map((c) => (
<option key={c.id} value={c.id}>
{c.externalUsername ?? c.id}
@ -94,13 +112,17 @@ export const SharePointNodeConfig: React.FC<NodeConfigRendererProps> = ({
)}
{needsPath && ['sharepoint.readFile', 'sharepoint.uploadFile', 'sharepoint.downloadFile'].includes(nodeType) && (
<div>
<label>Path</label>
<label>{nodeType === 'sharepoint.uploadFile' ? 'Target folder path' : 'Path'}</label>
<input
value={(params.path as string) ?? (params.filePath as string) ?? ''}
onChange={(e) =>
updateParam(nodeType === 'sharepoint.downloadFile' ? 'filePath' : 'path', e.target.value)
onChange={(e) => updateParam('path', e.target.value)}
placeholder={
nodeType === 'sharepoint.downloadFile'
? '/sites/SiteName/Shared Documents/file.pdf'
: nodeType === 'sharepoint.uploadFile'
? '/sites/.../Shared Documents/TargetFolder/'
: 'File or folder path'
}
placeholder="File or folder path"
/>
</div>
)}
@ -114,76 +136,109 @@ export const SharePointNodeConfig: React.FC<NodeConfigRendererProps> = ({
/>
</div>
)}
{nodeType === 'sharepoint.uploadFile' && (
<div>
<label>File name</label>
<input
value={(params.fileName as string) ?? ''}
onChange={(e) => updateParam('fileName', e.target.value)}
placeholder="file.pdf"
/>
</div>
)}
{nodeType === 'sharepoint.copyFile' && (
<>
<div>
<label>Source folder</label>
<input
value={(params.sourceFolder as string) ?? ''}
onChange={(e) => updateParam('sourceFolder', e.target.value)}
placeholder="Source folder path"
/>
</div>
<div>
<label>Source file</label>
<input
value={(params.sourceFile as string) ?? ''}
onChange={(e) => updateParam('sourceFile', e.target.value)}
placeholder="Source file name"
value={(params.sourcePath as string) ?? ''}
onChange={(e) => updateParam('sourcePath', e.target.value)}
placeholder="/sites/.../folder/file.pdf"
/>
</div>
<div>
<label>Dest folder</label>
<label>Destination folder</label>
<input
value={(params.destFolder as string) ?? ''}
onChange={(e) => updateParam('destFolder', e.target.value)}
placeholder="Destination folder path"
value={(params.destPath as string) ?? ''}
onChange={(e) => updateParam('destPath', e.target.value)}
placeholder="/sites/.../target-folder/"
/>
</div>
<div>
<label>Dest file</label>
<input
value={(params.destFile as string) ?? ''}
onChange={(e) => updateParam('destFile', e.target.value)}
placeholder="Destination file name"
/>
{connectionId && (
<>
<details
open={copySourceExpanded}
onToggle={(e) => setCopySourceExpanded((e.target as HTMLDetailsElement).open)}
style={{
marginTop: 12,
border: '1px solid var(--border-color, #e0e0e0)',
borderRadius: 6,
background: 'var(--bg-secondary, #f8f9fa)',
overflow: 'hidden',
}}
>
<summary style={{ padding: '0.5rem 0.75rem', cursor: 'pointer', fontWeight: 500, fontSize: '0.875rem' }}>
📂 Source file durchsuchen
</summary>
<div style={{ padding: '0.5rem 0.75rem', borderTop: '1px solid var(--border-color, #e0e0e0)', maxHeight: 280, overflowY: 'auto' }}>
<SharepointBrowseTree rootPath="/" onLoadChildren={loadChildren} onSelectFile={selectSourcePath} selectedPath={(params.sourcePath as string) || null} />
</div>
</details>
<details
open={copyDestExpanded}
onToggle={(e) => setCopyDestExpanded((e.target as HTMLDetailsElement).open)}
style={{
marginTop: 8,
border: '1px solid var(--border-color, #e0e0e0)',
borderRadius: 6,
background: 'var(--bg-secondary, #f8f9fa)',
overflow: 'hidden',
}}
>
<summary style={{ padding: '0.5rem 0.75rem', cursor: 'pointer', fontWeight: 500, fontSize: '0.875rem' }}>
📂 Zielordner durchsuchen
</summary>
<div style={{ padding: '0.5rem 0.75rem', borderTop: '1px solid var(--border-color, #e0e0e0)', maxHeight: 280, overflowY: 'auto' }}>
<SharepointBrowseTree rootPath="/" onLoadChildren={loadChildren} onSelectFile={() => {}} onSelectFolder={selectDestPath} selectedPath={(params.destPath as string) || null} />
</div>
</details>
</>
)}
{connectionId && needsPath && (
<div className="browse-section" style={{ marginTop: 12 }}>
<label>Browse: {currentPath}</label>
<ul style={{ maxHeight: 150, overflow: 'auto', listStyle: 'none', paddingLeft: 0 }}>
{currentPath !== '/' && (
<li>
<button type="button" onClick={() => navigateTo(currentPath.replace(/\/[^/]+$/, '') || '/')}>
.. (parent)
</button>
</li>
</>
)}
{browseItems.map((e) => (
<li key={e.path}>
<button
type="button"
onClick={() => (e.isFolder ? navigateTo(e.path) : selectPath(e.path))}
title={e.path}
{connectionId && needsPath && hasPathInput && !['sharepoint.copyFile'].includes(nodeType) && (
<details
open={browseExpanded}
onToggle={(e) => setBrowseExpanded((e.target as HTMLDetailsElement).open)}
style={{
marginTop: 12,
border: '1px solid var(--border-color, #e0e0e0)',
borderRadius: 6,
background: 'var(--bg-secondary, #f8f9fa)',
overflow: 'hidden',
}}
>
{e.isFolder ? '📁' : '📄'} {e.name}
</button>
</li>
))}
</ul>
<summary
style={{
padding: '0.5rem 0.75rem',
cursor: 'pointer',
fontWeight: 500,
fontSize: '0.875rem',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
userSelect: 'none',
}}
>
<span style={{ opacity: browseExpanded ? 0.7 : 1 }}>📂</span>
SharePoint durchsuchen
</summary>
<div
style={{
padding: '0.5rem 0.75rem',
borderTop: '1px solid var(--border-color, #e0e0e0)',
maxHeight: 280,
overflowY: 'auto',
}}
>
<SharepointBrowseTree
rootPath="/"
onLoadChildren={loadChildren}
onSelectFile={selectPath}
selectedPath={path || null}
/>
</div>
</details>
)}
</>
);

View file

@ -3,6 +3,29 @@
* Category ordering for node sidebar.
*/
/** Node type IDs hidden from the sidebar (hidden, not removed still work when present in saved graphs) */
export const HIDDEN_NODE_IDS = new Set([
'trigger.schedule', // zeitplan
'trigger.formSubmit', // formular-absendung
'flow.ifElse',
'flow.switch',
'flow.merge',
'flow.loop',
'flow.wait',
'flow.stop', // alle abschnitt ablauf
'data.setFields',
'data.filter',
'data.parseJson',
'data.template', // alle abschnitt daten
'ai.webResearch',
'ai.summarizeDocument',
'ai.translateDocument',
'ai.convertDocument',
'ai.generateDocument',
'ai.generateCode', // alle KI ausser ai.prompt
'sharepoint.listFiles', // dateien auflisten
]);
/** Default category display order */
export const CATEGORY_ORDER = [
'trigger',

View file

@ -0,0 +1,307 @@
/**
* SharepointBrowseTree Lazy-loading tree for SharePoint browse.
* Same look & feel as FolderTree (chevron, FaFolder/FaFolderOpen, styling).
* Loads children on expand via onLoadChildren(path).
*/
import React, { useState, useCallback, useEffect } from 'react';
import { FaFolder, FaFolderOpen, FaChevronRight, FaGlobe } from 'react-icons/fa';
import styles from './FolderTree.module.css';
export interface BrowseEntry {
name: string;
path: string;
isFolder: boolean;
size?: number;
mimeType?: string;
metadata?: Record<string, unknown>;
}
export interface SharepointBrowseTreeProps {
/** Root path (usually "/") - children loaded via onLoadChildren */
rootPath?: string;
/** Load children for a given path. Returns folders and files. */
onLoadChildren: (path: string) => Promise<BrowseEntry[]>;
/** Called when user selects a file path */
onSelectFile: (path: string) => void;
/** Called when user selects a folder path (e.g. for destination). If provided, folder rows are selectable. */
onSelectFolder?: (path: string) => void;
/** Currently selected path (for highlight) */
selectedPath?: string | null;
/** Optional: pre-seed root children (e.g. from initial load) */
initialChildren?: BrowseEntry[];
}
function _fileIcon(mime?: string): string {
if (!mime) return '\uD83D\uDCC4';
if (mime.startsWith('image/')) return '\uD83D\uDDBC\uFE0F';
if (mime.includes('pdf')) return '\uD83D\uDCD5';
if (mime.includes('word') || mime.includes('docx')) return '\uD83D\uDCD8';
if (mime.includes('sheet') || mime.includes('xlsx') || mime.includes('csv')) return '\uD83D\uDCCA';
if (mime.includes('presentation') || mime.includes('pptx')) return '\uD83D\uDCD9';
if (mime.includes('zip') || mime.includes('tar') || mime.includes('gz')) return '\uD83D\uDCE6';
if (mime.startsWith('text/') || mime.includes('json') || mime.includes('xml')) return '\uD83D\uDCDD';
return '\uD83D\uDCC4';
}
/* ── File row ──────────────────────────────────────────────────────────── */
function _FileRow({
entry,
selectedPath,
onSelect,
}: {
entry: BrowseEntry;
selectedPath: string | null | undefined;
onSelect: (path: string) => void;
}) {
const isSelected = selectedPath === entry.path;
return (
<div
className={`${styles.treeNode} ${styles.fileNode} ${isSelected ? styles.selected : ''}`}
onClick={() => onSelect(entry.path)}
title={entry.path}
>
<span className={styles.chevron + ' ' + styles.empty} />
<span className={styles.fileIcon}>{_fileIcon(entry.mimeType)}</span>
<span className={styles.folderName}>{entry.name}</span>
{entry.size != null && (
<span className={styles.fileSize}>
{(entry.size / 1024).toFixed(0)}K
</span>
)}
</div>
);
}
/* ── Folder row (expandable, lazy-loads children) ───────────────────────── */
function _FolderRow({
entry,
selectedPath,
expandedPaths,
loadedChildren,
loadingPaths,
onToggle,
onSelectFile,
onSelectFolder,
}: {
entry: BrowseEntry;
selectedPath: string | null | undefined;
expandedPaths: Set<string>;
loadedChildren: Record<string, BrowseEntry[]>;
loadingPaths: Set<string>;
onToggle: (path: string) => void;
onSelectFile: (path: string) => void;
onSelectFolder?: (path: string) => void;
}) {
const isExpanded = expandedPaths.has(entry.path);
const isSelected = selectedPath === entry.path;
const children = loadedChildren[entry.path] ?? [];
const folders = children.filter((c) => c.isFolder).sort((a, b) => a.name.localeCompare(b.name));
const files = children.filter((c) => !c.isFolder).sort((a, b) => a.name.localeCompare(b.name));
const hasChildren = folders.length > 0 || files.length > 0;
const isLoading = isExpanded && loadingPaths.has(entry.path);
const handleRowClick = (e: React.MouseEvent) => {
const target = e.target as HTMLElement;
if (target.closest(`.${styles.chevron}`)) return;
if (onSelectFolder) {
onSelectFolder(entry.path);
return;
}
onToggle(entry.path);
};
const handleChevronClick = (e: React.MouseEvent) => {
e.stopPropagation();
onToggle(entry.path);
};
return (
<div>
<div
className={`${styles.treeNode} ${onSelectFolder && isSelected ? styles.selected : ''}`}
onClick={handleRowClick}
title={entry.path}
>
<span
className={`${styles.chevron} ${isExpanded ? styles.expanded : ''}`}
onClick={handleChevronClick}
title={isExpanded ? 'Einklappen' : 'Erweitern'}
>
<FaChevronRight />
</span>
<span className={styles.folderIcon}>
{isExpanded ? <FaFolderOpen /> : <FaFolder />}
</span>
<span className={styles.folderName}>{entry.name}</span>
{isLoading && (
<span style={{ fontSize: 10, color: 'var(--color-text-secondary,#999)', marginLeft: 4 }}></span>
)}
</div>
{isExpanded && (
<div className={styles.children}>
{isLoading ? (
<div style={{ padding: '0.5rem 1rem', fontSize: '0.8rem', color: 'var(--color-text-secondary,#666)' }}>
Wird geladen
</div>
) : (
<>
{folders.map((child) => (
<_FolderRow
key={child.path}
entry={child}
selectedPath={selectedPath}
expandedPaths={expandedPaths}
loadedChildren={loadedChildren}
loadingPaths={loadingPaths}
onToggle={onToggle}
onSelectFile={onSelectFile}
onSelectFolder={onSelectFolder}
/>
))}
{files.map((child) => (
<_FileRow
key={child.path}
entry={child}
selectedPath={selectedPath}
onSelect={onSelectFile}
/>
))}
{children.length === 0 && (
<div style={{ padding: '0.4rem 1rem', fontSize: '0.8rem', color: 'var(--color-text-secondary,#999)' }}>
Leer
</div>
)}
</>
)}
</div>
)}
</div>
);
}
/* ── Root component ─────────────────────────────────────────────────────── */
export function SharepointBrowseTree({
rootPath = '/',
onLoadChildren,
onSelectFile,
onSelectFolder,
selectedPath,
initialChildren = [],
}: SharepointBrowseTreeProps) {
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set([rootPath]));
const [loadedChildren, setLoadedChildren] = useState<Record<string, BrowseEntry[]>>(() =>
initialChildren.length > 0 ? { [rootPath]: initialChildren } : {}
);
const [loadingPaths, setLoadingPaths] = useState<Set<string>>(new Set());
const loadPath = useCallback(
async (path: string) => {
setLoadingPaths((p) => new Set(p).add(path));
try {
const items = await onLoadChildren(path);
setLoadedChildren((prev) => ({ ...prev, [path]: items }));
} catch {
setLoadedChildren((prev) => ({ ...prev, [path]: [] }));
} finally {
setLoadingPaths((p) => {
const next = new Set(p);
next.delete(path);
return next;
});
}
},
[onLoadChildren]
);
const handleToggle = useCallback(
(path: string) => {
setExpandedPaths((prev) => {
const next = new Set(prev);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
loadPath(path);
}
return next;
});
},
[loadPath]
);
useEffect(() => {
if (rootPath in loadedChildren) return;
if (initialChildren.length > 0) return;
loadPath(rootPath);
}, [rootPath, initialChildren.length, loadPath]);
const rootItems = loadedChildren[rootPath] ?? [];
const rootLoading = loadingPaths.has(rootPath);
const rootFolders = rootItems.filter((e) => e.isFolder).sort((a, b) => a.name.localeCompare(b.name));
const rootFiles = rootItems.filter((e) => !e.isFolder).sort((a, b) => a.name.localeCompare(b.name));
const isRootExpanded = expandedPaths.has(rootPath);
return (
<div className={styles.folderTree}>
<div
className={`${styles.treeNode} ${selectedPath === null || selectedPath === undefined ? styles.selected : ''}`}
style={{ fontWeight: 600 }}
>
<span
className={`${styles.chevron} ${isRootExpanded ? styles.expanded : ''}`}
onClick={() => handleToggle(rootPath)}
>
<FaChevronRight />
</span>
<span className={styles.folderIcon}><FaGlobe /></span>
<span className={`${styles.folderName} ${styles.rootLabel}`}>SharePoint</span>
{rootLoading && (
<span style={{ fontSize: 10, color: 'var(--color-text-secondary,#999)', marginLeft: 4 }}></span>
)}
</div>
{isRootExpanded && (
<div className={styles.children}>
{rootLoading ? (
<div style={{ padding: '0.5rem 1rem', fontSize: '0.8rem', color: 'var(--color-text-secondary,#666)' }}>
Sites werden geladen
</div>
) : (
<>
{rootFolders.map((entry) => (
<_FolderRow
key={entry.path}
entry={entry}
selectedPath={selectedPath}
expandedPaths={expandedPaths}
loadedChildren={loadedChildren}
loadingPaths={loadingPaths}
onToggle={handleToggle}
onSelectFile={onSelectFile}
onSelectFolder={onSelectFolder}
/>
))}
{rootFiles.map((entry) => (
<_FileRow
key={entry.path}
entry={entry}
selectedPath={selectedPath}
onSelect={onSelectFile}
/>
))}
{rootItems.length === 0 && !rootLoading && (
<div style={{ padding: '0.5rem 1rem', fontSize: '0.8rem', color: 'var(--color-text-secondary,#999)' }}>
Keine Einträge
</div>
)}
</>
)}
</div>
)}
</div>
);
}