frontend_nyla/src/pages/views/workspace/DataSourcePanel.tsx
2026-03-15 23:38:44 +01:00

436 lines
15 KiB
TypeScript

/**
* 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<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',
};
/* ─── Component ─────────────────────────────────────────────────────── */
export const DataSourcePanel: React.FC<DataSourcePanelProps> = ({
instanceId,
dataSources,
onRefresh,
}) => {
const [tree, setTree] = useState<TreeNode[]>([]);
const [loadingRoot, setLoadingRoot] = useState(false);
const [addingPath, setAddingPath] = 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 || '/');
}
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,
});
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 (
<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 Sources
</div>
{dataSources.map(ds => (
<div key={ds.id} style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '4px 6px', borderRadius: 4, marginBottom: 2,
background: 'var(--primary-light, #e3f2fd)',
fontSize: 12,
}}>
<span style={{ color: '#4caf50', fontSize: 10 }}>{'\u25CF'}</span>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{ds.label}
</span>
<button
onClick={() => _removeDatasource(ds.id)}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 11, color: '#999', padding: '0 2px' }}
title="Remove"
>
{'\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}
/>
))}
</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>
);
};
/* ─── 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: '/',
}));
}
async function _browseService(
instanceId: string, connectionId: string, service: string, path: string,
): 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) => ({
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<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;
});
}