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

View file

@ -1,10 +1,12 @@
/** /**
* SharePoint node config - connection selector, path, search query. * 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 type { NodeConfigRendererProps } from './types';
import { fetchConnections, fetchBrowse, type UserConnection, type BrowseEntry } from '../../../api/automation2Api'; import { fetchConnections, fetchBrowse, type UserConnection, type BrowseEntry } from '../../../api/automation2Api';
import { SharepointBrowseTree } from '../../FolderTree/SharepointBrowseTree';
export const SharePointNodeConfig: React.FC<NodeConfigRendererProps> = ({ export const SharePointNodeConfig: React.FC<NodeConfigRendererProps> = ({
params, params,
@ -14,43 +16,62 @@ export const SharePointNodeConfig: React.FC<NodeConfigRendererProps> = ({
nodeType = 'sharepoint.findFile', nodeType = 'sharepoint.findFile',
}) => { }) => {
const [connections, setConnections] = useState<UserConnection[]>([]); const [connections, setConnections] = useState<UserConnection[]>([]);
const [browseItems, setBrowseItems] = useState<BrowseEntry[]>([]); const [browseExpanded, setBrowseExpanded] = useState(false);
const [currentPath, setCurrentPath] = useState('/'); const [copySourceExpanded, setCopySourceExpanded] = useState(false);
const [loading, setLoading] = useState(false); const [copyDestExpanded, setCopyDestExpanded] = useState(false);
const [connectionsLoading, setConnectionsLoading] = useState(false);
const connectionId = (params.connectionId as string) ?? ''; 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(() => { useEffect(() => {
if (instanceId && request) { if (instanceId && request) {
setLoading(true); setConnectionsLoading(true);
fetchConnections(request, instanceId) fetchConnections(request, instanceId)
.then(setConnections) .then(setConnections)
.catch(() => setConnections([])) .catch(() => setConnections([]))
.finally(() => setLoading(false)); .finally(() => setConnectionsLoading(false));
} }
}, [instanceId, request]); }, [instanceId, request]);
useEffect(() => { const loadChildren = useCallback(
if (instanceId && request && connectionId) { async (pathToLoad: string): Promise<BrowseEntry[]> => {
const service = 'sharepoint'; if (!instanceId || !request || !connectionId) return [];
fetchBrowse(request, instanceId, connectionId, service, currentPath) const r = await fetchBrowse(request, instanceId, connectionId, 'sharepoint', pathToLoad);
.then((r: { items: BrowseEntry[]; path: string; service: string }) => { return r?.items ?? [];
setBrowseItems(r.items); },
setCurrentPath(r.path); [instanceId, request, connectionId]
}) );
.catch(() => setBrowseItems([]));
} else {
setBrowseItems([]);
}
}, [instanceId, request, connectionId, currentPath]);
const navigateTo = (entryPath: string) => setCurrentPath(entryPath); const selectPath = useCallback(
const selectPath = (p: string) => updateParam('path', p); (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 needsPath = !['sharepoint.findFile'].includes(nodeType);
const needsSearch = nodeType === 'sharepoint.findFile'; 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 ( return (
<> <>
@ -58,13 +79,10 @@ export const SharePointNodeConfig: React.FC<NodeConfigRendererProps> = ({
<label>Connection</label> <label>Connection</label>
<select <select
value={connectionId} value={connectionId}
onChange={(e) => { onChange={(e) => updateParam('connectionId', e.target.value)}
updateParam('connectionId', e.target.value); disabled={connectionsLoading}
setCurrentPath('/');
}}
disabled={loading}
> >
<option value="">{loading ? 'Loading...' : 'Select connection'}</option> <option value="">{connectionsLoading ? 'Loading...' : 'Select connection'}</option>
{connections.map((c) => ( {connections.map((c) => (
<option key={c.id} value={c.id}> <option key={c.id} value={c.id}>
{c.externalUsername ?? 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) && ( {needsPath && ['sharepoint.readFile', 'sharepoint.uploadFile', 'sharepoint.downloadFile'].includes(nodeType) && (
<div> <div>
<label>Path</label> <label>{nodeType === 'sharepoint.uploadFile' ? 'Target folder path' : 'Path'}</label>
<input <input
value={(params.path as string) ?? (params.filePath as string) ?? ''} value={(params.path as string) ?? (params.filePath as string) ?? ''}
onChange={(e) => onChange={(e) => updateParam('path', e.target.value)}
updateParam(nodeType === 'sharepoint.downloadFile' ? 'filePath' : '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> </div>
)} )}
@ -114,76 +136,109 @@ export const SharePointNodeConfig: React.FC<NodeConfigRendererProps> = ({
/> />
</div> </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' && ( {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> <div>
<label>Source file</label> <label>Source file</label>
<input <input
value={(params.sourceFile as string) ?? ''} value={(params.sourcePath as string) ?? ''}
onChange={(e) => updateParam('sourceFile', e.target.value)} onChange={(e) => updateParam('sourcePath', e.target.value)}
placeholder="Source file name" placeholder="/sites/.../folder/file.pdf"
/> />
</div> </div>
<div> <div>
<label>Dest folder</label> <label>Destination folder</label>
<input <input
value={(params.destFolder as string) ?? ''} value={(params.destPath as string) ?? ''}
onChange={(e) => updateParam('destFolder', e.target.value)} onChange={(e) => updateParam('destPath', e.target.value)}
placeholder="Destination folder path" placeholder="/sites/.../target-folder/"
/> />
</div> </div>
<div> {connectionId && (
<label>Dest file</label> <>
<input <details
value={(params.destFile as string) ?? ''} open={copySourceExpanded}
onChange={(e) => updateParam('destFile', e.target.value)} onToggle={(e) => setCopySourceExpanded((e.target as HTMLDetailsElement).open)}
placeholder="Destination file name" 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> </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) => ( {connectionId && needsPath && hasPathInput && !['sharepoint.copyFile'].includes(nodeType) && (
<li key={e.path}> <details
<button open={browseExpanded}
type="button" onToggle={(e) => setBrowseExpanded((e.target as HTMLDetailsElement).open)}
onClick={() => (e.isFolder ? navigateTo(e.path) : selectPath(e.path))} style={{
title={e.path} marginTop: 12,
border: '1px solid var(--border-color, #e0e0e0)',
borderRadius: 6,
background: 'var(--bg-secondary, #f8f9fa)',
overflow: 'hidden',
}}
> >
{e.isFolder ? '📁' : '📄'} {e.name} <summary
</button> style={{
</li> padding: '0.5rem 0.75rem',
))} cursor: 'pointer',
</ul> 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> </div>
</details>
)} )}
</> </>
); );

View file

@ -3,6 +3,29 @@
* Category ordering for node sidebar. * 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 */ /** Default category display order */
export const CATEGORY_ORDER = [ export const CATEGORY_ORDER = [
'trigger', '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>
);
}