/** * SourcesTab – Full data-source management inside the Unified Data Bar. * * Tree structure (Browse Sources): * UserConnection (Level 1, loaded on mount) * └─ Service (Level 2, loaded when connection expanded) * └─ Folder / Site / File (Level 3+, loaded when service/folder expanded) * * Feature Data tree: * MandateGroup * └─ FeatureConnection (feature instance) * └─ FeatureTable (tables exposed by that instance) * * Active Sources sections show scope-cycling and neutralize-toggle buttons. */ import React, { useEffect, useState, useCallback, useRef } from 'react'; import type { UdbContext } from './UnifiedDataBar'; import api from '../../api'; import { getPageIcon } from '../../config/pageRegistry'; import styles from './SourcesTab.module.css'; /* ─── Types (inline, no external imports) ────────────────────────────── */ interface UdbDataSource { id: string; connectionId: string; sourceType: string; path: string; label: string; displayPath?: string; scope: string; neutralize: boolean; } interface UdbFeatureDataSource { id: string; featureInstanceId: string; featureCode: string; tableName: string; objectKey: string; label: string; scope: string; neutralize: boolean; } 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; 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[]; } /* ─── Props ──────────────────────────────────────────────────────────── */ interface SourcesTabProps { context: UdbContext; } /* ─── 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'; } /* ─── Scope / Neutralize constants ───────────────────────────────────── */ const _SCOPE_ORDER: string[] = ['personal', 'featureInstance', 'mandate']; const _SCOPE_ICONS: Record = { personal: '\uD83D\uDC64', featureInstance: '\uD83D\uDC65', mandate: '\uD83C\uDFE2', global: '\uD83C\uDF10', }; const _SCOPE_LABELS: Record = { personal: 'Personal', featureInstance: 'Feature Instance', mandate: 'Mandate', global: 'Global', }; function _nextScope(current: string): string { const idx = _SCOPE_ORDER.indexOf(current); if (idx === -1) return _SCOPE_ORDER[0]; return _SCOPE_ORDER[(idx + 1) % _SCOPE_ORDER.length]; } /* ─── Tree helpers ───────────────────────────────────────────────────── */ 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; }); } 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: UdbDataSource): 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: UdbFeatureDataSource, ): 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(' / '); } /* ─── Data fetching (module-level) ───────────────────────────────────── */ 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'; } /* ─── Spinner (inline) ───────────────────────────────────────────────── */ function _Spinner(): React.ReactElement { return ( ); } /* ─── Component ──────────────────────────────────────────────────────── */ const SourcesTab: React.FC = ({ context }) => { const instanceId = context.instanceId; /* ── Active sources (fetched internally) ── */ const [dataSources, setDataSources] = useState([]); const [featureDataSources, setFeatureDataSources] = useState([]); /* ── Browse tree state ── */ const [tree, setTree] = useState([]); const [loadingRoot, setLoadingRoot] = useState(false); const [addingPath, setAddingPath] = useState(null); /* ── Feature tree state ── */ 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; }; }, []); /* ── Fetch active personal data sources ── */ const _fetchDataSources = useCallback(() => { if (!instanceId) return; api.get(`/api/workspace/${instanceId}/datasources`) .then(res => { if (!mountedRef.current) return; const list: UdbDataSource[] = (res.data.dataSources || res.data || []).map((d: any) => ({ id: d.id, connectionId: d.connectionId, sourceType: d.sourceType, path: d.path, label: d.label, displayPath: d.displayPath, scope: d.scope || 'personal', neutralize: d.neutralize ?? false, })); setDataSources(list); }) .catch(() => { if (mountedRef.current) setDataSources([]); }); }, [instanceId]); /* ── Fetch active feature data sources ── */ const _fetchFeatureDataSources = useCallback(() => { if (!instanceId) return; api.get(`/api/workspace/${instanceId}/feature-datasources`) .then(res => { if (!mountedRef.current) return; const list: UdbFeatureDataSource[] = (res.data.featureDataSources || res.data || []).map((d: any) => ({ id: d.id, featureInstanceId: d.featureInstanceId, featureCode: d.featureCode, tableName: d.tableName, objectKey: d.objectKey, label: d.label, scope: d.scope || 'personal', neutralize: d.neutralize ?? false, })); setFeatureDataSources(list); }) .catch(() => { if (mountedRef.current) setFeatureDataSources([]); }); }, [instanceId]); useEffect(() => { _fetchDataSources(); }, [_fetchDataSources]); useEffect(() => { _fetchFeatureDataSources(); }, [_fetchFeatureDataSources]); /* ── 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, }); _fetchDataSources(); } catch (err) { console.error('Failed to add data source:', err); } finally { if (mountedRef.current) setAddingPath(null); } }, [instanceId, _fetchDataSources]); /* ── Remove DataSource ── */ const _removeDatasource = useCallback(async (dsId: string) => { try { await api.delete(`/api/workspace/${instanceId}/datasources/${dsId}`); _fetchDataSources(); } catch (err) { console.error('Failed to remove data source:', err); } }, [instanceId, _fetchDataSources]); /* ── 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]); /* ── Scope change (personal data source, optimistic) ── */ const _cyclePersonalScope = useCallback(async (ds: UdbDataSource) => { const newScope = _nextScope(ds.scope); setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, scope: newScope } : d)); try { await api.patch(`/api/datasources/${ds.id}/scope`, { scope: newScope }); } catch { setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, scope: ds.scope } : d)); } }, []); /* ── Neutralize toggle (personal data source, optimistic) ── */ const _togglePersonalNeutralize = useCallback(async (ds: UdbDataSource) => { const newValue = !ds.neutralize; setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, neutralize: newValue } : d)); try { await api.patch(`/api/datasources/${ds.id}/neutralize`, { neutralize: newValue }); } catch { setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, neutralize: ds.neutralize } : d)); } }, []); /* ── Scope change (feature data source, optimistic) ── */ const _cycleFeatureScope = useCallback(async (fds: UdbFeatureDataSource) => { const newScope = _nextScope(fds.scope); setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, scope: newScope } : d)); try { await api.patch(`/api/datasources/${fds.id}/scope`, { scope: newScope }); } catch { setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, scope: fds.scope } : d)); } }, []); /* ── Neutralize toggle (feature data source, optimistic) ── */ const _toggleFeatureNeutralize = useCallback(async (fds: UdbFeatureDataSource) => { const newValue = !fds.neutralize; setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, neutralize: newValue } : d)); try { await api.patch(`/api/datasources/${fds.id}/neutralize`, { neutralize: newValue }); } catch { setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, neutralize: fds.neutralize } : d)); } }, []); /* ── 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, }); _fetchFeatureDataSources(); } catch (err) { console.error('Failed to add feature data source:', err); } finally { if (mountedRef.current) setAddingFeatureKey(null); } }, [instanceId, _fetchFeatureDataSources]); /* ── Feature: Remove FeatureDataSource ── */ const _removeFeatureDataSource = useCallback(async (fdsId: string) => { try { await api.delete(`/api/workspace/${instanceId}/feature-datasources/${fdsId}`); _fetchFeatureDataSources(); } catch (err) { console.error('Failed to remove feature data source:', err); } }, [instanceId, _fetchFeatureDataSources]); /* ── 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]); /* ── Render ── */ return (
{/* ── Active Personal Sources ── */} {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}
); })}
)} {/* ── Browse Sources header ── */}
Browse Sources
{/* ── 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} /> ))} {/* ── Divider ── */}
{/* ── Active Feature 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 Data header ── */}
Feature Data
{/* ── Feature Data tree ── */} {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<_TreeNodeViewProps> = ({ 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'} )}
{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<_MandateGroupViewProps> = ({ 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<_FeatureNodeViewProps> = ({ 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)
)}
); }; /* ─── FeatureTableRow ────────────────────────────────────────────────── */ interface _FeatureTableRowProps { featureNode: FeatureConnectionNode; table: FeatureTableNode; onAdd: (node: FeatureConnectionNode, table: FeatureTableNode) => void; isAdded: boolean; isAdding: boolean; } const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({ 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'} )}
); }; export default SourcesTab;