diff --git a/src/pages/views/workspace/DataSourcePanel.tsx b/src/pages/views/workspace/DataSourcePanel.tsx index 656472f..e343792 100644 --- a/src/pages/views/workspace/DataSourcePanel.tsx +++ b/src/pages/views/workspace/DataSourcePanel.tsx @@ -27,12 +27,15 @@ interface TreeNode { 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; @@ -41,6 +44,13 @@ interface FeatureConnectionNode { tables: FeatureTableNode[] | null; } +interface MandateGroupNode { + mandateId: string; + mandateLabel: string; + expanded: boolean; + featureConnections: FeatureConnectionNode[]; +} + interface FeatureTableNode { objectKey: string; tableName: string; @@ -102,6 +112,53 @@ function _getSourceIcon(sourceType: string): string { 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 = ({ @@ -114,7 +171,7 @@ export const DataSourcePanel: React.FC = ({ const [tree, setTree] = useState([]); const [loadingRoot, setLoadingRoot] = useState(false); const [addingPath, setAddingPath] = useState(null); - const [featureTree, setFeatureTree] = useState([]); + const [featureTree, setFeatureTree] = useState([]); const [loadingFeatures, setLoadingFeatures] = useState(false); const [addingFeatureKey, setAddingFeatureKey] = useState(null); const mountedRef = useRef(true); @@ -174,7 +231,13 @@ export const DataSourcePanel: React.FC = ({ 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 || '/'); + children = await _browseService( + instanceId, + node.connectionId, + node.service!, + node.path || '/', + node.displayPath || node.label, + ); } if (mountedRef.current) { @@ -205,6 +268,7 @@ export const DataSourcePanel: React.FC = ({ sourceType: sourceTypeMap[node.service] || node.service, path: node.path || '/', label: node.label, + displayPath: node.displayPath || node.label, }); onRefresh(); } catch (err) { @@ -238,16 +302,22 @@ export const DataSourcePanel: React.FC = ({ api.get(`/api/workspace/${instanceId}/feature-connections`) .then(res => { if (!mountedRef.current) return; - const conns = res.data.featureConnections || []; - setFeatureTree(conns.map((c: any) => ({ - featureInstanceId: c.featureInstanceId, - featureCode: c.featureCode, - label: c.label, - icon: c.icon || '\uD83D\uDDC3\uFE0F', - tableCount: c.tableCount || 0, - expanded: false, - loading: false, - tables: null, + 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([]); }) @@ -256,25 +326,28 @@ export const DataSourcePanel: React.FC = ({ 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 => prev.map(n => - n.featureInstanceId === node.featureInstanceId ? { ...n, expanded: false } : n - )); + setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ ...n, expanded: false }))); return; } if (node.tables !== null) { - setFeatureTree(prev => prev.map(n => - n.featureInstanceId === node.featureInstanceId ? { ...n, expanded: true } : n - )); + setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ ...n, expanded: true }))); return; } - setFeatureTree(prev => prev.map(n => - n.featureInstanceId === node.featureInstanceId ? { ...n, loading: true, expanded: true } : n - )); + 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`); @@ -285,15 +358,15 @@ export const DataSourcePanel: React.FC = ({ fields: t.fields || [], })); if (mountedRef.current) { - setFeatureTree(prev => prev.map(n => - n.featureInstanceId === node.featureInstanceId ? { ...n, loading: false, tables } : n - )); + setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ + ...n, loading: false, tables, + }))); } } catch { if (mountedRef.current) { - setFeatureTree(prev => prev.map(n => - n.featureInstanceId === node.featureInstanceId ? { ...n, loading: false, tables: [] } : n - )); + setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ + ...n, loading: false, tables: [], + }))); } } }, [instanceId]); @@ -341,7 +414,7 @@ export const DataSourcePanel: React.FC = ({ {dataSources.length > 0 && (
- Active Sources + Active Personal Sources
{dataSources.map(ds => { const connColor = _getSourceColor(ds.sourceType); @@ -355,7 +428,7 @@ export const DataSourcePanel: React.FC = ({ background: `${connColor}18`, borderLeft: `3px solid ${connColor}`, fontSize: 12, - }} title={`${connLabel} – ${ds.path || ds.label}`}> + }} title={_personalDataSourceHoverTitle(connLabel, ds)}> {_getSourceIcon(ds.sourceType)} {connLabel} – {folder} @@ -423,7 +496,8 @@ export const DataSourcePanel: React.FC = ({ Active Feature Sources
{featureDataSources.map(fds => { - const fdsConnLabel = featureTree.find(n => n.featureInstanceId === fds.featureInstanceId)?.label || fds.label; + const meta = _findFeatureInstanceMeta(featureTree, fds.featureInstanceId); + const fdsConnLabel = meta?.instanceLabel || fds.tableName; return (
= ({ background: '#7b1fa218', borderLeft: '3px solid #7b1fa2', fontSize: 12, - }} title={`${fdsConnLabel} - ${fds.tableName}`}> + }} title={_featureDataSourceHoverTitle(meta, fds)}> {getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDDC3\uFE0F'} @@ -477,11 +551,12 @@ export const DataSourcePanel: React.FC = ({
)} - {featureTree.map(fNode => ( - <_FeatureNodeView - key={fNode.featureInstanceId} - node={fNode} - onToggle={_toggleFeatureNode} + {featureTree.map(g => ( + <_MandateGroupView + key={g.mandateId} + group={g} + onToggleGroup={_toggleMandateGroup} + onToggleFeature={_toggleFeatureNode} onAddTable={_addFeatureTable} isTableAdded={_isFeatureTableAdded} addingKey={addingFeatureKey} @@ -596,6 +671,63 @@ const _TreeNodeView: React.FC = ({ ); }; +/* ─── 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 { @@ -748,28 +880,40 @@ async function _loadServices(instanceId: string, connectionId: string): Promise< connectionId, service: s.service, path: '/', + displayPath: s.label || s.service, })); } async function _browseService( - instanceId: string, connectionId: string, service: string, path: string, + 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) => ({ - 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, - })); + 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 { diff --git a/src/pages/views/workspace/useWorkspace.ts b/src/pages/views/workspace/useWorkspace.ts index 3359f52..d8ff7c4 100644 --- a/src/pages/views/workspace/useWorkspace.ts +++ b/src/pages/views/workspace/useWorkspace.ts @@ -54,6 +54,8 @@ export interface DataSource { sourceType: string; path: string; label: string; + /** Human-readable full path (service + folders); used for tooltips */ + displayPath?: string; } export interface FeatureDataSource {