/** * DataSourcePanel -- Browse external data sources as a lazy-loading tree. * * Tree structure: * UserConnection (Level 1, loaded on mount) * └─ Service (Level 2, loaded when connection expanded) * └─ Folder / Site / File (Level 3+, loaded when service/folder expanded) * * Each folder node can be added as a DataSource for this workspace instance. */ import React, { useEffect, useState, useCallback, useRef } from 'react'; import api from '../../../api'; import type { DataSource } from './useWorkspace'; /* ─── Types ─────────────────────────────────────────────────────────── */ interface TreeNode { key: string; label: string; icon: string; type: 'connection' | 'service' | 'folder' | 'file'; expanded: boolean; loading: boolean; children: TreeNode[] | null; connectionId: string; service?: string; path?: string; authority?: string; } interface DataSourcePanelProps { instanceId: string; dataSources: DataSource[]; onRefresh: () => void; } /* ─── Icons ─────────────────────────────────────────────────────────── */ const _AUTHORITY_ICONS: Record = { msft: '\uD83D\uDFE6', google: '\uD83D\uDFE9', 'local:ftp': '\uD83D\uDD17', 'local:jira': '\uD83D\uDD27', }; const _SERVICE_ICONS: Record = { sharepoint: '\uD83D\uDCC1', onedrive: '\u2601\uFE0F', outlook: '\uD83D\uDCE7', teams: '\uD83D\uDCAC', drive: '\uD83D\uDCC2', gmail: '\uD83D\uDCE8', files: '\uD83D\uDCC2', }; /* ─── Component ─────────────────────────────────────────────────────── */ export const DataSourcePanel: React.FC = ({ instanceId, dataSources, onRefresh, }) => { const [tree, setTree] = useState([]); const [loadingRoot, setLoadingRoot] = useState(false); const [addingPath, setAddingPath] = useState(null); const mountedRef = useRef(true); useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); /* ── Load Level 1: UserConnections ── */ const _loadConnections = useCallback(() => { if (!instanceId) return; setLoadingRoot(true); api.get(`/api/workspace/${instanceId}/connections`) .then(res => { if (!mountedRef.current) return; const conns = res.data.connections || []; const nodes: TreeNode[] = conns .filter((c: any) => c.status === 'active') .map((c: any) => ({ key: `conn-${c.id}`, label: c.externalEmail || c.externalUsername || c.authority, icon: _AUTHORITY_ICONS[c.authority] || '\uD83D\uDD17', type: 'connection' as const, expanded: false, loading: false, children: null, connectionId: c.id, authority: c.authority, })); setTree(nodes); }) .catch(() => { if (mountedRef.current) setTree([]); }) .finally(() => { if (mountedRef.current) setLoadingRoot(false); }); }, [instanceId]); useEffect(() => { _loadConnections(); }, [_loadConnections]); /* ── Generic tree update helper ── */ const _updateNode = useCallback((key: string, updater: (node: TreeNode) => TreeNode) => { setTree(prev => _mapTree(prev, key, updater)); }, []); /* ── Toggle expand/collapse ── */ const _toggleNode = useCallback(async (node: TreeNode) => { if (node.expanded) { _updateNode(node.key, n => ({ ...n, expanded: false })); return; } if (node.children !== null) { _updateNode(node.key, n => ({ ...n, expanded: true })); return; } _updateNode(node.key, n => ({ ...n, loading: true, expanded: true })); try { let children: TreeNode[] = []; if (node.type === 'connection') { children = await _loadServices(instanceId, node.connectionId); } else if (node.type === 'service' || node.type === 'folder') { children = await _browseService(instanceId, node.connectionId, node.service!, node.path || '/'); } if (mountedRef.current) { _updateNode(node.key, n => ({ ...n, loading: false, children })); } } catch { if (mountedRef.current) { _updateNode(node.key, n => ({ ...n, loading: false, children: [] })); } } }, [instanceId, _updateNode]); /* ── Add as DataSource ── */ const _addAsDataSource = useCallback(async (node: TreeNode) => { if (!node.service || !node.connectionId) return; setAddingPath(node.key); try { const sourceTypeMap: Record = { sharepoint: 'sharepointFolder', onedrive: 'onedriveFolder', outlook: 'outlookFolder', drive: 'googleDriveFolder', gmail: 'gmailFolder', files: 'ftpFolder', }; await api.post(`/api/workspace/${instanceId}/datasources`, { connectionId: node.connectionId, sourceType: sourceTypeMap[node.service] || node.service, path: node.path || '/', label: node.label, }); onRefresh(); } catch (err) { console.error('Failed to add data source:', err); } finally { if (mountedRef.current) setAddingPath(null); } }, [instanceId, onRefresh]); /* ── Remove DataSource ── */ const _removeDatasource = useCallback(async (dsId: string) => { try { await api.delete(`/api/workspace/${instanceId}/datasources/${dsId}`); onRefresh(); } catch (err) { console.error('Failed to remove data source:', err); } }, [instanceId, onRefresh]); /* ── Check if a path is already added ── */ const _isAdded = useCallback((connectionId: string, service: string | undefined, path: string | undefined): boolean => { return dataSources.some(ds => ds.connectionId === connectionId && ds.path === (path || '/'), ); }, [dataSources]); return (
{/* Active DataSources */} {dataSources.length > 0 && (
Active Sources
{dataSources.map(ds => (
{'\u25CF'} {ds.label}
))}
)} {/* Tree header */}
Browse Sources
{/* Tree */} {loadingRoot && tree.length === 0 && (
Loading connections...
)} {!loadingRoot && tree.length === 0 && (
No active connections found.
)} {tree.map(node => ( <_TreeNodeView key={node.key} node={node} depth={0} onToggle={_toggleNode} onAdd={_addAsDataSource} isAdded={_isAdded} addingPath={addingPath} /> ))}
); }; /* ─── TreeNodeView (recursive) ──────────────────────────────────────── */ interface TreeNodeViewProps { node: TreeNode; depth: number; onToggle: (node: TreeNode) => void; onAdd: (node: TreeNode) => void; isAdded: (connectionId: string, service: string | undefined, path: string | undefined) => boolean; addingPath: string | null; } const _TreeNodeView: React.FC = ({ node, depth, onToggle, onAdd, isAdded, addingPath, }) => { const [hovered, setHovered] = useState(false); const hasChildren = node.type !== 'file'; const chevron = hasChildren ? (node.expanded ? '\u25BE' : '\u25B8') : '\u00A0\u00A0'; const canAdd = node.type === 'folder' || node.type === 'service'; const alreadyAdded = canAdd && isAdded(node.connectionId, node.service, node.path); const isAdding = addingPath === node.key; return (
{ if (hasChildren) onToggle(node); }} onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} style={{ display: 'flex', alignItems: 'center', gap: 4, paddingLeft: depth * 16 + 4, paddingRight: 4, paddingTop: 3, paddingBottom: 3, cursor: hasChildren ? 'pointer' : 'default', borderRadius: 3, background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent', transition: 'background 0.1s', userSelect: 'none', }} > {node.loading ? _Spinner() : chevron} {node.icon} {node.label} {canAdd && hovered && !alreadyAdded && ( )} {canAdd && alreadyAdded && ( {'\u2713'} )}
{/* Children */} {node.expanded && node.children && node.children.length > 0 && (
{node.children.map(child => ( <_TreeNodeView key={child.key} node={child} depth={depth + 1} onToggle={onToggle} onAdd={onAdd} isAdded={isAdded} addingPath={addingPath} /> ))}
)} {node.expanded && node.children && node.children.length === 0 && !node.loading && (
(empty)
)}
); }; /* ─── Spinner (inline) ──────────────────────────────────────────────── */ function _Spinner(): React.ReactElement { return ( ); } /* ─── Data fetching ─────────────────────────────────────────────────── */ async function _loadServices(instanceId: string, connectionId: string): Promise { const res = await api.get(`/api/workspace/${instanceId}/connections/${connectionId}/services`); const services = res.data.services || []; return services.map((s: any) => ({ key: `svc-${connectionId}-${s.service}`, label: s.label || s.service, icon: _SERVICE_ICONS[s.service] || '\uD83D\uDCC2', type: 'service' as const, expanded: false, loading: false, children: null, connectionId, service: s.service, path: '/', })); } async function _browseService( instanceId: string, connectionId: string, service: string, path: string, ): Promise { const res = await api.get(`/api/workspace/${instanceId}/connections/${connectionId}/browse`, { params: { service, path }, }); const items = res.data.items || []; return items.map((entry: any, idx: number) => ({ key: `item-${connectionId}-${service}-${entry.path || idx}`, label: entry.name, icon: entry.isFolder ? '\uD83D\uDCC1' : _fileIcon(entry.name), type: entry.isFolder ? 'folder' as const : 'file' as const, expanded: false, loading: false, children: entry.isFolder ? null : [], connectionId, service, path: entry.path, })); } function _fileIcon(name: string): string { const ext = name.split('.').pop()?.toLowerCase() || ''; const map: Record = { pdf: '\uD83D\uDCC4', doc: '\uD83D\uDCDD', docx: '\uD83D\uDCDD', xls: '\uD83D\uDCCA', xlsx: '\uD83D\uDCCA', csv: '\uD83D\uDCCA', ppt: '\uD83D\uDCC8', pptx: '\uD83D\uDCC8', txt: '\uD83D\uDCC4', json: '\uD83D\uDCCB', png: '\uD83D\uDDBC\uFE0F', jpg: '\uD83D\uDDBC\uFE0F', jpeg: '\uD83D\uDDBC\uFE0F', zip: '\uD83D\uDCE6', rar: '\uD83D\uDCE6', mp3: '\uD83C\uDFB5', wav: '\uD83C\uDFB5', mp4: '\uD83C\uDFAC', mov: '\uD83C\uDFAC', }; return map[ext] || '\uD83D\uDCC4'; } /* ─── Tree map utility ──────────────────────────────────────────────── */ function _mapTree(nodes: TreeNode[], key: string, updater: (n: TreeNode) => TreeNode): TreeNode[] { return nodes.map(n => { if (n.key === key) return updater(n); if (n.children) return { ...n, children: _mapTree(n.children, key, updater) }; return n; }); }