ui-nyla/src/components/UnifiedDataBar/UdbSourcesProvider.tsx
ValueOn AG 5711450606
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 48s
fix: UDB compact layout, mobile table view, DataSource ID attach
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 00:00:38 +02:00

388 lines
15 KiB
TypeScript

// Copyright (c) 2026 Patrick Motsch
// All rights reserved.
/**
* UdbSourcesProvider — TreeNodeProvider for the UDB Sources tab.
*
* Single responsibility: translate the generic UDB backend tree contract
* (POST /api/udb/tree/children -> nodesByParent map) into the generic
* TreeNode shape that FormGeneratorTree consumes, and forward flag
* mutations to the single generic UDB endpoint
* (POST /api/udb/node/{key}/flag/{flag}).
*
* No effective-value computation, no inheritance logic, no mixed-state math:
* the backend is the single source of truth. The provider only:
* 1. caches the most recently loaded backend node payload per id so the
* drag/settings handlers have direct access to coordinates,
* 2. emits stable display ordering via `displayOrder`,
* 3. hides flag affordances on synthetic container nodes (synthRoot,
* mandateGroup) by leaving the corresponding TreeNode field undefined.
*
* The UDB API endpoints are intentionally feature-agnostic; they do not
* carry an `instanceId` in the path. The legacy `instanceId` argument is
* still accepted by `createUdbSourcesProvider` because:
* - the drag payloads for "create personal DataSource" / "create FDS"
* hit feature-instance-scoped helper endpoints under /api/workspace/
* to keep ingestion/RAG bound to the caller's workspace,
* - the rootKey is namespaced per instance so two UDBs on the same
* screen never collide.
*/
import React from 'react';
import {
FaGoogle, FaMicrosoft, FaTasks, FaCloud, FaLink, FaFolder, FaFile, FaEnvelope,
FaCloudUploadAlt, FaCalendarAlt, FaComments, FaUser, FaTable, FaDatabase,
FaBuilding,
} from 'react-icons/fa';
import { SiJira } from 'react-icons/si';
import api from '../../api';
import type { TreeNode, TreeNodeProvider, ScopeValue } from '../FormGenerator/FormGeneratorTree';
// ---------------------------------------------------------------------------
// Backend contract types
// ---------------------------------------------------------------------------
export type UdbBackendKind =
| 'synthRoot'
| 'connection' | 'service' | 'folder' | 'file'
| 'mandateGroup' | 'featureNode' | 'fdsTable' | 'fdsRecord' | 'fdsField';
export interface UdbBackendNode {
key: string;
kind: UdbBackendKind;
parentKey: string | null;
label: string;
icon?: string;
hasChildren: boolean;
dataSourceId: string | null;
modelType: 'DataSource' | 'FeatureDataSource' | null;
effectiveNeutralize: boolean | 'mixed';
effectiveScope: string;
effectiveRagIndexEnabled: boolean | 'mixed';
supportsRag: boolean;
canBeAdded: boolean;
displayOrder?: number;
defaultExpanded?: boolean;
authority?: string;
connectionId?: string;
service?: string;
sourceType?: string;
path?: string;
featureInstanceId?: string;
featureCode?: string;
mandateId?: string;
tableName?: string;
fieldName?: string;
objectKey?: string;
displayPath?: string;
/** fdsTable-only: persisted list of column names to neutralize (PII mask). */
neutralizeFields?: string[];
}
/** Kinds that represent the *root* of a data source (one DataSource record per
* node). Settings are only meaningful here; folders/files/services/tables
* inherit settings from the root and don't get their own gear icon. */
const _DATA_SOURCE_ROOT_KINDS = new Set<UdbBackendKind>([
'connection',
'featureNode',
]);
// ---------------------------------------------------------------------------
// Icon resolution (kept inline; UDB-domain mapping, not Tree-generic)
// ---------------------------------------------------------------------------
const _AUTHORITY_ICONS: Record<string, React.ReactNode> = {
msft: <FaMicrosoft style={{ color: '#00a4ef', fontSize: 12 }} />,
google: <FaGoogle style={{ color: '#4285f4', fontSize: 12 }} />,
clickup: <FaTasks style={{ color: '#7b68ee', fontSize: 12 }} />,
infomaniak: <FaCloud style={{ color: '#0098db', fontSize: 12 }} />,
'local:ftp': <FaLink style={{ color: '#795548', fontSize: 12 }} />,
'local:jira': <SiJira style={{ color: '#0052CC', fontSize: 12 }} />,
};
const _SERVICE_ICONS: Record<string, React.ReactNode> = {
sharepoint: <FaFolder style={{ color: '#0078d4', fontSize: 11 }} />,
onedrive: <FaCloudUploadAlt style={{ color: '#0078d4', fontSize: 11 }} />,
outlook: <FaEnvelope style={{ color: '#0078d4', fontSize: 11 }} />,
teams: <FaComments style={{ color: '#6264a7', fontSize: 11 }} />,
drive: <FaCloudUploadAlt style={{ color: '#4285f4', fontSize: 11 }} />,
gmail: <FaEnvelope style={{ color: '#ea4335', fontSize: 11 }} />,
files: <FaLink style={{ color: '#795548', fontSize: 11 }} />,
kdrive: <FaCloudUploadAlt style={{ color: '#0098db', fontSize: 11 }} />,
calendar: <FaCalendarAlt style={{ color: '#888', fontSize: 11 }} />,
contact: <FaUser style={{ color: '#888', fontSize: 11 }} />,
};
const _KIND_FALLBACK_ICONS: Record<string, React.ReactNode> = {
synthRoot: <FaDatabase style={{ color: '#666', fontSize: 12 }} />,
connection: <FaLink style={{ color: '#888', fontSize: 12 }} />,
service: <FaFolder style={{ color: '#888', fontSize: 11 }} />,
folder: <FaFolder style={{ color: '#888', fontSize: 11 }} />,
file: <FaFile style={{ color: '#888', fontSize: 11 }} />,
mandateGroup: <FaBuilding style={{ color: '#7b1fa2', fontSize: 12 }} />,
featureNode: <FaDatabase style={{ color: '#7b1fa2', fontSize: 11 }} />,
fdsTable: <FaTable style={{ color: '#7b1fa2', fontSize: 11 }} />,
fdsRecord: <FaFile style={{ color: '#7b1fa2', fontSize: 11 }} />,
fdsField: <span style={{ color: '#9e9e9e', fontSize: 10, fontFamily: 'monospace' }}>{'\u22EE'}</span>,
};
function _renderIcon(node: UdbBackendNode): React.ReactNode {
if (node.kind === 'connection') {
return _AUTHORITY_ICONS[node.icon || ''] ?? _KIND_FALLBACK_ICONS.connection;
}
if (node.kind === 'service') {
return _SERVICE_ICONS[node.icon || ''] ?? _KIND_FALLBACK_ICONS.service;
}
return _KIND_FALLBACK_ICONS[node.kind] ?? null;
}
// ---------------------------------------------------------------------------
// Domain rule: which kinds expose flag toggles
// ---------------------------------------------------------------------------
/** Synthetic / structural containers carry no DB record and have no flags.
* The provider hides scope/neutralize/ragIndexEnabled for them so the tree
* doesn't render dead buttons. */
function _isSyntheticContainer(kind: UdbBackendKind): boolean {
return kind === 'synthRoot' || kind === 'mandateGroup';
}
/** FDS-family kinds (no `scope` attribute; visibility is feature RBAC). */
function _isFdsKind(kind: UdbBackendKind): boolean {
return kind === 'featureNode' || kind === 'fdsTable' || kind === 'fdsRecord' || kind === 'fdsField';
}
// ---------------------------------------------------------------------------
// Mapping: backend payload -> generic TreeNode
// ---------------------------------------------------------------------------
function _mapBackendNode(
n: UdbBackendNode,
onSettingsClick: (n: UdbBackendNode) => Promise<void> | void,
): TreeNode<UdbBackendNode> {
const isSynthetic = _isSyntheticContainer(n.kind);
const isFolderLike = n.hasChildren;
const node: TreeNode<UdbBackendNode> = {
id: n.key,
name: n.label,
type: isFolderLike ? 'folder' : 'file',
parentId: n.parentKey,
ownership: 'own',
icon: _renderIcon(n),
displayOrder: n.displayOrder,
defaultExpanded: n.defaultExpanded,
data: n,
};
if (!isSynthetic) {
if (n.kind === 'fdsField') {
// Fields expose ONLY neutralize (mapped to parent table's
// neutralizeFields list). Scope and RAG are not field-level concepts.
node.neutralize = n.effectiveNeutralize;
} else if (_isFdsKind(n.kind)) {
// FDS records have neutralize + ragIndexEnabled, but no scope.
node.neutralize = n.effectiveNeutralize;
if (n.supportsRag) {
node.ragIndexEnabled = n.effectiveRagIndexEnabled;
}
} else {
// DataSource family carries the full three-flag set.
node.scope = n.effectiveScope as ScopeValue | 'mixed';
node.neutralize = n.effectiveNeutralize;
if (n.supportsRag) {
node.ragIndexEnabled = n.effectiveRagIndexEnabled;
}
}
}
if (_DATA_SOURCE_ROOT_KINDS.has(n.kind)) {
node.extraActions = [{
key: 'settings',
icon: '\u2699\uFE0F',
tooltip: 'Einstellungen',
onClick: () => onSettingsClick(n),
}];
}
return node;
}
// ---------------------------------------------------------------------------
// Provider factory
// ---------------------------------------------------------------------------
export interface UdbSourcesProviderHandle extends TreeNodeProvider<UdbBackendNode> {
/** Resolve the DataSource UUID for a node, creating a record if needed. */
ensureDataSourceId(node: UdbBackendNode): Promise<string | null>;
/** Test/diagnostic hook only -- exposes the latest cached backend payloads
* so consumers can inspect data flow without round-tripping through the
* network. Not part of the contract used at runtime. */
_diagnosticGetCacheSize(): number;
}
export function createUdbSourcesProvider(
instanceId: string,
onOpenSettings: (dataSourceId: string, label: string) => void,
): UdbSourcesProviderHandle {
// Per-id cache of the most recent backend payload. Used by the
// settings/drag handlers (NOT by the generic flag PATCH path; that
// identifies the node purely by its tree key).
const nodeCache = new Map<string, UdbBackendNode>();
async function _ensureRecordForSettings(node: UdbBackendNode): Promise<string | null> {
if (node.dataSourceId) return node.dataSourceId;
try {
if (node.kind === 'connection' || node.kind === 'service'
|| node.kind === 'folder' || node.kind === 'file') {
const sourceType = node.sourceType
|| (node.kind === 'connection' ? node.authority : '')
|| '';
const res = await api.post(`/api/workspace/${instanceId}/datasources`, {
connectionId: node.connectionId || '',
sourceType,
path: node.path || '/',
label: node.label,
displayPath: node.displayPath || node.label,
});
const newId: string | null = res.data?.id ?? null;
if (newId) {
nodeCache.set(node.key, { ...node, dataSourceId: newId, modelType: 'DataSource' });
}
return newId;
}
if (node.kind === 'featureNode' || node.kind === 'fdsTable' || node.kind === 'fdsRecord') {
const tableName = node.tableName || (node.kind === 'featureNode' ? '*' : '');
const objectKey = node.objectKey
|| (node.kind === 'featureNode' ? `data.feature.${node.featureCode}.*` : '');
const res = await api.post(`/api/workspace/${instanceId}/feature-datasources`, {
featureInstanceId: node.featureInstanceId || '',
featureCode: node.featureCode || '',
tableName,
objectKey,
label: node.label,
});
const newId: string | null = res.data?.id ?? null;
if (newId) {
nodeCache.set(node.key, { ...node, dataSourceId: newId, modelType: 'FeatureDataSource' });
}
return newId;
}
} catch (err) {
console.error('[UdbSourcesProvider] ensureRecordForSettings failed', err);
}
return null;
}
async function _onSettingsClick(node: UdbBackendNode): Promise<void> {
const dsId = await _ensureRecordForSettings(node);
if (!dsId) {
console.warn('[UdbSourcesProvider] settings click: cannot ensure record', node.key);
return;
}
onOpenSettings(dsId, node.label);
}
/** Forward a flag mutation to the generic UDB endpoint. The backend
* resolves the node from `nodeKey`, runs the polymorphic `canEdit`
* permission check, and applies the cascade-reset. */
async function _patchFlag(
ids: string[],
flag: 'neutralize' | 'scope' | 'ragIndexEnabled',
value: unknown,
): Promise<void> {
for (const nodeKey of ids) {
try {
await api.post(`/api/udb/node/${encodeURIComponent(nodeKey)}/flag/${flag}`, { value });
} catch (err) {
console.error('[UdbSourcesProvider] patch failed', { nodeKey, flag, err });
throw err;
}
}
}
return {
rootKey: `udb-sources-${instanceId}`,
async loadChildren(parentId, _ownership) {
const res = await api.post(`/api/udb/tree/children`, {
parents: [parentId],
});
const nodesByParent = res.data?.nodesByParent || {};
const lookupKey = parentId ?? '__root__';
const list: UdbBackendNode[] = nodesByParent[lookupKey] || [];
for (const n of list) nodeCache.set(n.key, n);
return list.map((n) => _mapBackendNode(n, _onSettingsClick));
},
canPatchScope(node) {
const data = node.data;
// Scope only exists on DataSource family; FDS / synthetic containers / fields hide it.
return !!data && !_isSyntheticContainer(data.kind) && !_isFdsKind(data.kind);
},
canPatchNeutralize(node) {
const data = node.data;
return !!data && !_isSyntheticContainer(data.kind);
},
canPatchRagIndex(node) {
const data = node.data;
// RAG exists at the data-source level (DS root / FDS table+rows), never on fields.
return !!data && data.supportsRag === true && data.kind !== 'fdsField';
},
async patchScope(ids, scope, _cascadeChildren) {
// Backend cascades NULL on descendants automatically based on the
// existence of explicit child records; the cascadeChildren flag is the
// FilesTab convention and is irrelevant here.
await _patchFlag(ids, 'scope', scope);
},
async patchNeutralize(ids, neutralize) {
await _patchFlag(ids, 'neutralize', neutralize);
},
async patchRagIndex(ids, ragIndexEnabled) {
await _patchFlag(ids, 'ragIndexEnabled', ragIndexEnabled);
},
customizeDragData(node, dataTransfer) {
const data = node.data as UdbBackendNode | undefined;
if (!data || _isSyntheticContainer(data.kind)) return;
if (data.kind === 'connection' || data.kind === 'service'
|| data.kind === 'folder' || data.kind === 'file') {
const sourceType = data.sourceType
|| (data.kind === 'connection' ? data.authority : '') || '';
const payload = {
connectionId: data.connectionId || '',
sourceType,
path: data.path || '/',
label: data.label,
displayPath: data.displayPath || data.label,
};
dataTransfer.setData('application/datasource', JSON.stringify(payload));
} else if (data.kind === 'featureNode' || data.kind === 'fdsTable' || data.kind === 'fdsRecord') {
const tableName = data.tableName || (data.kind === 'featureNode' ? '*' : '');
const objectKey = data.objectKey
|| (data.kind === 'featureNode' ? `data.feature.${data.featureCode}.*` : '');
const payload = {
featureInstanceId: data.featureInstanceId || '',
featureCode: data.featureCode || '',
tableName,
objectKey,
label: data.label,
};
dataTransfer.setData('application/feature-source', JSON.stringify(payload));
}
},
async ensureDataSourceId(node: UdbBackendNode): Promise<string | null> {
return _ensureRecordForSettings(node);
},
_diagnosticGetCacheSize() {
return nodeCache.size;
},
};
}