1134 lines
41 KiB
TypeScript
1134 lines
41 KiB
TypeScript
/**
|
||
* 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<string, string>;
|
||
fields: string[];
|
||
}
|
||
|
||
/* ─── Props ──────────────────────────────────────────────────────────── */
|
||
|
||
interface SourcesTabProps {
|
||
context: UdbContext;
|
||
}
|
||
|
||
/* ─── Icons ──────────────────────────────────────────────────────────── */
|
||
|
||
const _AUTHORITY_ICONS: Record<string, string> = {
|
||
msft: '\uD83D\uDFE6',
|
||
google: '\uD83D\uDFE9',
|
||
clickup: '\uD83D\uDCCB',
|
||
'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';
|
||
}
|
||
|
||
/* ─── Scope / Neutralize constants ───────────────────────────────────── */
|
||
|
||
const _SCOPE_ORDER: string[] = ['personal', 'featureInstance', 'mandate'];
|
||
|
||
const _SCOPE_ICONS: Record<string, string> = {
|
||
personal: '\uD83D\uDC64',
|
||
featureInstance: '\uD83D\uDC65',
|
||
mandate: '\uD83C\uDFE2',
|
||
global: '\uD83C\uDF10',
|
||
};
|
||
|
||
const _SCOPE_LABELS: Record<string, string> = {
|
||
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<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';
|
||
}
|
||
|
||
/* ─── 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',
|
||
}} />
|
||
);
|
||
}
|
||
|
||
/* ─── Component ──────────────────────────────────────────────────────── */
|
||
|
||
const SourcesTab: React.FC<SourcesTabProps> = ({ context }) => {
|
||
const instanceId = context.instanceId;
|
||
|
||
/* ── Active sources (fetched internally) ── */
|
||
const [dataSources, setDataSources] = useState<UdbDataSource[]>([]);
|
||
const [featureDataSources, setFeatureDataSources] = useState<UdbFeatureDataSource[]>([]);
|
||
|
||
/* ── Browse tree state ── */
|
||
const [tree, setTree] = useState<TreeNode[]>([]);
|
||
const [loadingRoot, setLoadingRoot] = useState(false);
|
||
const [addingPath, setAddingPath] = useState<string | null>(null);
|
||
|
||
/* ── Feature tree state ── */
|
||
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; }; }, []);
|
||
|
||
/* ── 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<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,
|
||
});
|
||
_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 (
|
||
<div className={styles.sourcesTab} style={{ padding: 8, fontSize: 13 }}>
|
||
{/* ── Active Personal Sources ── */}
|
||
{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={() => _cyclePersonalScope(ds)}
|
||
style={{
|
||
background: 'none', border: 'none', cursor: 'pointer',
|
||
fontSize: 13, padding: '0 2px', lineHeight: 1,
|
||
}}
|
||
title={`Scope: ${_SCOPE_LABELS[ds.scope] || ds.scope} → ${_SCOPE_LABELS[_nextScope(ds.scope)]}`}
|
||
>
|
||
{_SCOPE_ICONS[ds.scope] || _SCOPE_ICONS.personal}
|
||
</button>
|
||
<button
|
||
onClick={() => _togglePersonalNeutralize(ds)}
|
||
style={{
|
||
background: 'none', border: 'none', cursor: 'pointer',
|
||
fontSize: 13, padding: '0 2px', lineHeight: 1,
|
||
opacity: ds.neutralize ? 1 : 0.35,
|
||
}}
|
||
title={ds.neutralize ? 'Neutralize: ON (click to deactivate)' : 'Neutralize: OFF (click to activate)'}
|
||
>
|
||
{'\uD83D\uDD12'}
|
||
</button>
|
||
<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>
|
||
)}
|
||
|
||
{/* ── Browse Sources 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>
|
||
|
||
{/* ── Browse Sources 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}
|
||
/>
|
||
))}
|
||
|
||
{/* ── Divider ── */}
|
||
<div style={{ height: 1, background: 'var(--border-color, #e0e0e0)', margin: '12px 0 8px' }} />
|
||
|
||
{/* ── Active Feature 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={() => _cycleFeatureScope(fds)}
|
||
style={{
|
||
background: 'none', border: 'none', cursor: 'pointer',
|
||
fontSize: 13, padding: '0 2px', lineHeight: 1,
|
||
}}
|
||
title={`Scope: ${_SCOPE_LABELS[fds.scope] || fds.scope} → ${_SCOPE_LABELS[_nextScope(fds.scope)]}`}
|
||
>
|
||
{_SCOPE_ICONS[fds.scope] || _SCOPE_ICONS.personal}
|
||
</button>
|
||
<button
|
||
onClick={() => _toggleFeatureNeutralize(fds)}
|
||
style={{
|
||
background: 'none', border: 'none', cursor: 'pointer',
|
||
fontSize: 13, padding: '0 2px', lineHeight: 1,
|
||
opacity: fds.neutralize ? 1 : 0.35,
|
||
}}
|
||
title={fds.neutralize ? 'Neutralize: ON (click to deactivate)' : 'Neutralize: OFF (click to activate)'}
|
||
>
|
||
{'\uD83D\uDD12'}
|
||
</button>
|
||
<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 Data header ── */}
|
||
<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>
|
||
|
||
{/* ── Feature Data tree ── */}
|
||
{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>
|
||
|
||
{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>
|
||
);
|
||
};
|
||
|
||
/* ─── 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 (
|
||
<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>
|
||
);
|
||
};
|
||
|
||
export default SourcesTab;
|