/** * 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 { getPageIcon } from '../../../config/pageRegistry'; import type { DataSource, FeatureDataSource } 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; /** Breadcrumb for tooltips and persisted displayPath (service + folder segments) */ displayPath?: string; authority?: string; } interface FeatureConnectionNode { featureInstanceId: string; featureCode: string; mandateId?: string; label: string; icon: string; tableCount: number; expanded: boolean; loading: boolean; tables: FeatureTableNode[] | null; } interface MandateGroupNode { mandateId: string; mandateLabel: string; expanded: boolean; featureConnections: FeatureConnectionNode[]; } interface FeatureTableNode { objectKey: string; tableName: string; label: Record; fields: string[]; } interface DataSourcePanelProps { instanceId: string; dataSources: DataSource[]; featureDataSources: FeatureDataSource[]; onRefresh: () => void; onRefreshFeatureDataSources: () => 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', }; /* ─── Source colors & icons ──────────────────────────────────────────── */ const _SOURCE_COLORS: Record = { sharepointFolder: '#0078d4', onedriveFolder: '#0078d4', outlookFolder: '#0078d4', googleDriveFolder: '#34a853', gmailFolder: '#ea4335', ftpFolder: '#795548', }; function _getSourceColor(sourceType: string): string { return _SOURCE_COLORS[sourceType] || '#1976d2'; } function _getSourceIcon(sourceType: string): string { const map: Record = { sharepointFolder: '\uD83D\uDCC1', onedriveFolder: '\u2601\uFE0F', outlookFolder: '\uD83D\uDCE7', googleDriveFolder: '\uD83D\uDCC2', gmailFolder: '\uD83D\uDCE8', ftpFolder: '\uD83D\uDD17', }; return map[sourceType] || '\uD83D\uDCC1'; } function _mapFeatureTreeUpdate( prev: MandateGroupNode[], featureInstanceId: string, updater: (n: FeatureConnectionNode) => FeatureConnectionNode, ): MandateGroupNode[] { return prev.map(g => ({ ...g, featureConnections: g.featureConnections.map(n => n.featureInstanceId === featureInstanceId ? updater(n) : n ), })); } function _findFeatureInstanceMeta( groups: MandateGroupNode[], featureInstanceId: string, ): { mandateLabel: string; instanceLabel: string } | null { for (const g of groups) { const fc = g.featureConnections.find(f => f.featureInstanceId === featureInstanceId); if (fc) return { mandateLabel: g.mandateLabel, instanceLabel: fc.label }; } return null; } function _personalDataSourceHoverTitle(connLabel: string, ds: DataSource): string { const pathPart = (ds.displayPath && ds.displayPath.trim()) || ds.label || ds.path || ''; return pathPart ? `${connLabel} / ${pathPart}` : connLabel; } function _featureDataSourceHoverTitle( meta: { mandateLabel: string; instanceLabel: string } | null, fds: FeatureDataSource, ): string { const parts: string[] = []; if (meta) { parts.push(meta.mandateLabel, meta.instanceLabel); } const labelPart = fds.label && fds.tableName && fds.label !== fds.tableName ? `${fds.label} (${fds.tableName})` : (fds.label || fds.tableName); parts.push(labelPart); if (fds.objectKey && fds.objectKey !== labelPart && !labelPart.includes(fds.objectKey)) { parts.push(fds.objectKey); } return parts.join(' / '); } /* ─── Component ─────────────────────────────────────────────────────── */ export const DataSourcePanel: React.FC = ({ instanceId, dataSources, featureDataSources, onRefresh, onRefreshFeatureDataSources, }) => { const [tree, setTree] = useState([]); const [loadingRoot, setLoadingRoot] = useState(false); const [addingPath, setAddingPath] = useState(null); const [featureTree, setFeatureTree] = useState([]); const [loadingFeatures, setLoadingFeatures] = useState(false); const [addingFeatureKey, setAddingFeatureKey] = 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 || '/', node.displayPath || node.label, ); } 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, displayPath: node.displayPath || 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]); /* ── Feature Connections: Load Level 1 ── */ const _loadFeatureConnections = useCallback(() => { if (!instanceId) return; setLoadingFeatures(true); api.get(`/api/workspace/${instanceId}/feature-connections`) .then(res => { if (!mountedRef.current) return; const groups = res.data.featureConnectionsByMandate || []; setFeatureTree(groups.map((g: any) => ({ mandateId: g.mandateId, mandateLabel: g.mandateLabel || g.mandateId, expanded: true, featureConnections: (g.featureConnections || []).map((c: any) => ({ featureInstanceId: c.featureInstanceId, featureCode: c.featureCode, mandateId: c.mandateId, label: c.label, icon: c.icon || '\uD83D\uDDC3\uFE0F', tableCount: c.tableCount || 0, expanded: false, loading: false, tables: null, })), }))); }) .catch(() => { if (mountedRef.current) setFeatureTree([]); }) .finally(() => { if (mountedRef.current) setLoadingFeatures(false); }); }, [instanceId]); useEffect(() => { _loadFeatureConnections(); }, [_loadFeatureConnections]); /* ── Feature Connections: Toggle mandate group ── */ const _toggleMandateGroup = useCallback((mandateId: string) => { setFeatureTree(prev => prev.map(g => g.mandateId === mandateId ? { ...g, expanded: !g.expanded } : g )); }, []); /* ── Feature Connections: Toggle expand ── */ const _toggleFeatureNode = useCallback(async (node: FeatureConnectionNode) => { if (node.expanded) { setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ ...n, expanded: false }))); return; } if (node.tables !== null) { setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ ...n, expanded: true }))); return; } setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ ...n, loading: true, expanded: true, }))); try { const res = await api.get(`/api/workspace/${instanceId}/feature-connections/${node.featureInstanceId}/tables`); const tables: FeatureTableNode[] = (res.data.tables || []).map((t: any) => ({ objectKey: t.objectKey, tableName: t.tableName, label: t.label || {}, fields: t.fields || [], })); if (mountedRef.current) { setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ ...n, loading: false, tables, }))); } } catch { if (mountedRef.current) { setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ ...n, loading: false, tables: [], }))); } } }, [instanceId]); /* ── Feature: Add table as FeatureDataSource ── */ const _addFeatureTable = useCallback(async (node: FeatureConnectionNode, table: FeatureTableNode) => { const key = `${node.featureInstanceId}-${table.tableName}`; setAddingFeatureKey(key); try { await api.post(`/api/workspace/${instanceId}/feature-datasources`, { featureInstanceId: node.featureInstanceId, featureCode: node.featureCode, tableName: table.tableName, objectKey: table.objectKey, label: table.label?.en || table.label?.de || table.tableName, }); onRefreshFeatureDataSources(); } catch (err) { console.error('Failed to add feature data source:', err); } finally { if (mountedRef.current) setAddingFeatureKey(null); } }, [instanceId, onRefreshFeatureDataSources]); /* ── Feature: Remove FeatureDataSource ── */ const _removeFeatureDataSource = useCallback(async (fdsId: string) => { try { await api.delete(`/api/workspace/${instanceId}/feature-datasources/${fdsId}`); onRefreshFeatureDataSources(); } catch (err) { console.error('Failed to remove feature data source:', err); } }, [instanceId, onRefreshFeatureDataSources]); /* ── Feature: check if table already added ── */ const _isFeatureTableAdded = useCallback((featureInstanceId: string, tableName: string): boolean => { return featureDataSources.some(fds => fds.featureInstanceId === featureInstanceId && fds.tableName === tableName, ); }, [featureDataSources]); return (
{/* Active DataSources */} {dataSources.length > 0 && (
Active Personal Sources
{dataSources.map(ds => { const connColor = _getSourceColor(ds.sourceType); const connNode = tree.find(n => n.connectionId === ds.connectionId); const connLabel = connNode?.label || ds.connectionId; const folder = ds.label || ds.path || ds.id; return (
{_getSourceIcon(ds.sourceType)} {connLabel} – {folder}
); })}
)} {/* 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} /> ))} {/* ── Feature Data Section ── */}
{/* Active Feature Data Sources */} {featureDataSources.length > 0 && (
Active Feature Sources
{featureDataSources.map(fds => { const meta = _findFeatureInstanceMeta(featureTree, fds.featureInstanceId); const fdsConnLabel = meta?.instanceLabel || fds.tableName; return (
{getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDDC3\uFE0F'} {fdsConnLabel} – {fds.tableName}
); })}
)} {/* Feature Connections Tree */}
Feature Data
{loadingFeatures && featureTree.length === 0 && (
Loading feature instances...
)} {!loadingFeatures && featureTree.length === 0 && (
No feature instances found.
)} {featureTree.map(g => ( <_MandateGroupView key={g.mandateId} group={g} onToggleGroup={_toggleMandateGroup} onToggleFeature={_toggleFeatureNode} onAddTable={_addFeatureTable} isTableAdded={_isFeatureTableAdded} addingKey={addingFeatureKey} /> ))}
); }; /* ─── 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)
)}
); }; /* ─── MandateGroupView (mandate + feature instances) ───────────────── */ interface MandateGroupViewProps { group: MandateGroupNode; onToggleGroup: (mandateId: string) => void; onToggleFeature: (node: FeatureConnectionNode) => void; onAddTable: (node: FeatureConnectionNode, table: FeatureTableNode) => void; isTableAdded: (featureInstanceId: string, tableName: string) => boolean; addingKey: string | null; } const _MandateGroupView: React.FC = ({ group, onToggleGroup, onToggleFeature, onAddTable, isTableAdded, addingKey, }) => { const [hovered, setHovered] = useState(false); const chevron = group.expanded ? '\u25BE' : '\u25B8'; return (
onToggleGroup(group.mandateId)} onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} style={{ display: 'flex', alignItems: 'center', gap: 4, paddingLeft: 4, paddingRight: 4, paddingTop: 3, paddingBottom: 3, cursor: 'pointer', borderRadius: 3, background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent', transition: 'background 0.1s', userSelect: 'none', }} > {chevron} {group.mandateLabel}
{group.expanded && (
{group.featureConnections.map(fNode => ( <_FeatureNodeView key={fNode.featureInstanceId} node={fNode} onToggle={onToggleFeature} onAddTable={onAddTable} isTableAdded={isTableAdded} addingKey={addingKey} /> ))}
)}
); }; /* ─── FeatureNodeView (feature instance + tables) ─────────────────── */ interface FeatureNodeViewProps { node: FeatureConnectionNode; onToggle: (node: FeatureConnectionNode) => void; onAddTable: (node: FeatureConnectionNode, table: FeatureTableNode) => void; isTableAdded: (featureInstanceId: string, tableName: string) => boolean; addingKey: string | null; } const _FeatureNodeView: React.FC = ({ node, onToggle, onAddTable, isTableAdded, addingKey, }) => { const [hovered, setHovered] = useState(false); const chevron = node.expanded ? '\u25BE' : '\u25B8'; return (
onToggle(node)} onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} style={{ display: 'flex', alignItems: 'center', gap: 4, paddingLeft: 4, paddingRight: 4, paddingTop: 3, paddingBottom: 3, cursor: 'pointer', borderRadius: 3, background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent', transition: 'background 0.1s', userSelect: 'none', }} > {node.loading ? _Spinner() : chevron} {getPageIcon(`feature.${node.featureCode}`) || '\uD83D\uDDC3\uFE0F'} {node.label} {node.tableCount} tables
{node.expanded && node.tables && node.tables.length > 0 && (
{node.tables.map(table => ( <_FeatureTableRow key={table.objectKey} featureNode={node} table={table} onAdd={onAddTable} isAdded={isTableAdded(node.featureInstanceId, table.tableName)} isAdding={addingKey === `${node.featureInstanceId}-${table.tableName}`} /> ))}
)} {node.expanded && node.tables && node.tables.length === 0 && !node.loading && (
(no tables)
)}
); }; interface FeatureTableRowProps { featureNode: FeatureConnectionNode; table: FeatureTableNode; onAdd: (node: FeatureConnectionNode, table: FeatureTableNode) => void; isAdded: boolean; isAdding: boolean; } const _FeatureTableRow: React.FC = ({ featureNode, table, onAdd, isAdded, isAdding, }) => { const [hovered, setHovered] = useState(false); const tableLabel = table.label?.en || table.label?.de || table.tableName; return (
setHovered(true)} onMouseLeave={() => setHovered(false)} style={{ display: 'flex', alignItems: 'center', gap: 4, paddingLeft: 36, paddingRight: 4, paddingTop: 3, paddingBottom: 3, borderRadius: 3, background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent', transition: 'background 0.1s', userSelect: 'none', }} title={`${table.tableName}: ${table.fields.join(', ')}`} > {'\uD83D\uDCC1'} {tableLabel} {hovered && !isAdded && ( )} {isAdded && ( {'\u2713'} )}
); }; /* ─── 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: '/', displayPath: s.label || s.service, })); } async function _browseService( instanceId: string, connectionId: string, service: string, path: string, parentDisplayPath: string | undefined, ): 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) => { const seg = entry.name || ''; const displayPath = parentDisplayPath ? `${parentDisplayPath} / ${seg}` : seg; return { 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, displayPath, }; }); } 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; }); }