sharepoint nodes
This commit is contained in:
parent
af58d5a868
commit
896f7b5968
4 changed files with 479 additions and 93 deletions
|
|
@ -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) ||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
307
src/components/FolderTree/SharepointBrowseTree.tsx
Normal file
307
src/components/FolderTree/SharepointBrowseTree.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue