All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 48s
Co-authored-by: Cursor <cursoragent@cursor.com>
388 lines
15 KiB
TypeScript
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;
|
|
},
|
|
};
|
|
}
|