frontend_nyla/src/pages/views/workspace/DataSourcePanel.tsx
2026-03-22 11:09:52 +01:00

942 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<string, string>;
fields: string[];
}
interface DataSourcePanelProps {
instanceId: string;
dataSources: DataSource[];
featureDataSources: FeatureDataSource[];
onRefresh: () => void;
onRefreshFeatureDataSources: () => void;
}
/* ─── Icons ─────────────────────────────────────────────────────────── */
const _AUTHORITY_ICONS: Record<string, string> = {
msft: '\uD83D\uDFE6',
google: '\uD83D\uDFE9',
'local:ftp': '\uD83D\uDD17',
'local:jira': '\uD83D\uDD27',
};
const _SERVICE_ICONS: Record<string, string> = {
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<string, string> = {
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<string, string> = {
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<DataSourcePanelProps> = ({
instanceId,
dataSources,
featureDataSources,
onRefresh,
onRefreshFeatureDataSources,
}) => {
const [tree, setTree] = useState<TreeNode[]>([]);
const [loadingRoot, setLoadingRoot] = useState(false);
const [addingPath, setAddingPath] = useState<string | null>(null);
const [featureTree, setFeatureTree] = useState<MandateGroupNode[]>([]);
const [loadingFeatures, setLoadingFeatures] = useState(false);
const [addingFeatureKey, setAddingFeatureKey] = useState<string | null>(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<string, string> = {
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 (
<div style={{ padding: 8, fontSize: 13 }}>
{/* Active DataSources */}
{dataSources.length > 0 && (
<div style={{ marginBottom: 8 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: '#666', textTransform: 'uppercase', marginBottom: 4 }}>
Active Personal Sources
</div>
{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 (
<div key={ds.id} style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '4px 6px', borderRadius: 4, marginBottom: 2,
background: `${connColor}18`,
borderLeft: `3px solid ${connColor}`,
fontSize: 12,
}} title={_personalDataSourceHoverTitle(connLabel, ds)}>
<span style={{ fontSize: 12, flexShrink: 0 }}>{_getSourceIcon(ds.sourceType)}</span>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{connLabel} {folder}
</span>
<button
onClick={() => _removeDatasource(ds.id)}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 11, color: '#999', padding: '0 2px' }}
title="Entfernen"
>
{'\u2715'}
</button>
</div>
);
})}
<div style={{ height: 1, background: 'var(--border-color, #e0e0e0)', margin: '8px 0' }} />
</div>
)}
{/* Tree header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
<span style={{ fontSize: 11, fontWeight: 600, color: '#666', textTransform: 'uppercase' }}>
Browse Sources
</span>
<button
onClick={_loadConnections}
disabled={loadingRoot}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#1976d2' }}
>
{loadingRoot ? '...' : '\u21BB'}
</button>
</div>
{/* Tree */}
{loadingRoot && tree.length === 0 && (
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
Loading connections...
</div>
)}
{!loadingRoot && tree.length === 0 && (
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
No active connections found.
</div>
)}
{tree.map(node => (
<_TreeNodeView
key={node.key}
node={node}
depth={0}
onToggle={_toggleNode}
onAdd={_addAsDataSource}
isAdded={_isAdded}
addingPath={addingPath}
/>
))}
{/* ── Feature Data Section ── */}
<div style={{ height: 1, background: 'var(--border-color, #e0e0e0)', margin: '12px 0 8px' }} />
{/* Active Feature Data Sources */}
{featureDataSources.length > 0 && (
<div style={{ marginBottom: 8 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: '#666', textTransform: 'uppercase', marginBottom: 4 }}>
Active Feature Sources
</div>
{featureDataSources.map(fds => {
const meta = _findFeatureInstanceMeta(featureTree, fds.featureInstanceId);
const fdsConnLabel = meta?.instanceLabel || fds.tableName;
return (
<div key={fds.id} style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '4px 6px', borderRadius: 4, marginBottom: 2,
background: '#7b1fa218',
borderLeft: '3px solid #7b1fa2',
fontSize: 12,
}} title={_featureDataSourceHoverTitle(meta, fds)}>
<span style={{ fontSize: 12, flexShrink: 0, display: 'flex', alignItems: 'center', color: '#7b1fa2' }}>
{getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDDC3\uFE0F'}
</span>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{fdsConnLabel} {fds.tableName}
</span>
<button
onClick={() => _removeFeatureDataSource(fds.id)}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 11, color: '#999', padding: '0 2px' }}
title="Entfernen"
>
{'\u2715'}
</button>
</div>
); })}
<div style={{ height: 1, background: 'var(--border-color, #e0e0e0)', margin: '8px 0' }} />
</div>
)}
{/* Feature Connections Tree */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
<span style={{ fontSize: 11, fontWeight: 600, color: '#666', textTransform: 'uppercase' }}>
Feature Data
</span>
<button
onClick={_loadFeatureConnections}
disabled={loadingFeatures}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#7b1fa2' }}
>
{loadingFeatures ? '...' : '\u21BB'}
</button>
</div>
{loadingFeatures && featureTree.length === 0 && (
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
Loading feature instances...
</div>
)}
{!loadingFeatures && featureTree.length === 0 && (
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
No feature instances found.
</div>
)}
{featureTree.map(g => (
<_MandateGroupView
key={g.mandateId}
group={g}
onToggleGroup={_toggleMandateGroup}
onToggleFeature={_toggleFeatureNode}
onAddTable={_addFeatureTable}
isTableAdded={_isFeatureTableAdded}
addingKey={addingFeatureKey}
/>
))}
</div>
);
};
/* ─── 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 (
<div>
<div
onClick={() => { 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',
}}
>
<span style={{ fontSize: 10, color: '#888', width: 12, textAlign: 'center', flexShrink: 0 }}>
{node.loading ? _Spinner() : chevron}
</span>
<span style={{ fontSize: 14, flexShrink: 0 }}>{node.icon}</span>
<span style={{
flex: 1, minWidth: 0, overflow: 'hidden',
textOverflow: 'ellipsis', whiteSpace: 'nowrap',
fontSize: 12,
fontWeight: node.type === 'connection' ? 600 : 400,
}}>
{node.label}
</span>
{canAdd && hovered && !alreadyAdded && (
<button
onClick={e => { e.stopPropagation(); onAdd(node); }}
disabled={isAdding}
style={{
background: 'none', border: '1px solid #1976d2', borderRadius: 3,
cursor: isAdding ? 'not-allowed' : 'pointer',
fontSize: 10, color: '#1976d2', padding: '1px 5px',
opacity: isAdding ? 0.5 : 1,
flexShrink: 0,
}}
title="Add as data source"
>
{isAdding ? '...' : '+ Add'}
</button>
)}
{canAdd && alreadyAdded && (
<span style={{ fontSize: 10, color: '#4caf50', flexShrink: 0 }} title="Already added">
{'\u2713'}
</span>
)}
</div>
{/* Children */}
{node.expanded && node.children && node.children.length > 0 && (
<div>
{node.children.map(child => (
<_TreeNodeView
key={child.key}
node={child}
depth={depth + 1}
onToggle={onToggle}
onAdd={onAdd}
isAdded={isAdded}
addingPath={addingPath}
/>
))}
</div>
)}
{node.expanded && node.children && node.children.length === 0 && !node.loading && (
<div style={{ paddingLeft: (depth + 1) * 16 + 20, fontSize: 11, color: '#bbb', padding: '2px 0 2px ' + ((depth + 1) * 16 + 20) + 'px' }}>
(empty)
</div>
)}
</div>
);
};
/* ─── 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 (
<div>
<div
onClick={() => 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',
}}
>
<span style={{ fontSize: 10, color: '#888', width: 12, textAlign: 'center', flexShrink: 0 }}>
{chevron}
</span>
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 12, fontWeight: 700, color: '#555' }}>
{group.mandateLabel}
</span>
</div>
{group.expanded && (
<div style={{ paddingLeft: 10 }}>
{group.featureConnections.map(fNode => (
<_FeatureNodeView
key={fNode.featureInstanceId}
node={fNode}
onToggle={onToggleFeature}
onAddTable={onAddTable}
isTableAdded={isTableAdded}
addingKey={addingKey}
/>
))}
</div>
)}
</div>
);
};
/* ─── 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 (
<div>
<div
onClick={() => 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',
}}
>
<span style={{ fontSize: 10, color: '#888', width: 12, textAlign: 'center', flexShrink: 0 }}>
{node.loading ? _Spinner() : chevron}
</span>
<span style={{ fontSize: 13, flexShrink: 0, display: 'flex', alignItems: 'center', color: '#7b1fa2' }}>
{getPageIcon(`feature.${node.featureCode}`) || '\uD83D\uDDC3\uFE0F'}
</span>
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 12, fontWeight: 600 }}>
{node.label}
</span>
<span style={{ fontSize: 10, color: '#999', flexShrink: 0 }}>
{node.tableCount} tables
</span>
</div>
{node.expanded && node.tables && node.tables.length > 0 && (
<div>
{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}`}
/>
))}
</div>
)}
{node.expanded && node.tables && node.tables.length === 0 && !node.loading && (
<div style={{ paddingLeft: 36, fontSize: 11, color: '#bbb', padding: '2px 0 2px 36px' }}>
(no tables)
</div>
)}
</div>
);
};
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 (
<div
onMouseEnter={() => 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(', ')}`}
>
<span style={{ fontSize: 14, flexShrink: 0 }}>{'\uD83D\uDCC1'}</span>
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 12 }}>
{tableLabel}
</span>
{hovered && !isAdded && (
<button
onClick={() => onAdd(featureNode, table)}
disabled={isAdding}
style={{
background: 'none', border: '1px solid #7b1fa2', borderRadius: 3,
cursor: isAdding ? 'not-allowed' : 'pointer',
fontSize: 10, color: '#7b1fa2', padding: '1px 5px',
opacity: isAdding ? 0.5 : 1, flexShrink: 0,
}}
title="Add as feature data source"
>
{isAdding ? '...' : '+ Add'}
</button>
)}
{isAdded && (
<span style={{ fontSize: 10, color: '#4caf50', flexShrink: 0 }} title="Already added">
{'\u2713'}
</span>
)}
</div>
);
};
/* ─── Spinner (inline) ──────────────────────────────────────────────── */
function _Spinner(): React.ReactElement {
return (
<span style={{
display: 'inline-block', width: 10, height: 10,
border: '1.5px solid #ccc', borderTopColor: '#1976d2',
borderRadius: '50%',
animation: 'spin 0.6s linear infinite',
}} />
);
}
/* ─── Data fetching ─────────────────────────────────────────────────── */
async function _loadServices(instanceId: string, connectionId: string): Promise<TreeNode[]> {
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<TreeNode[]> {
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<string, string> = {
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;
});
}