diff --git a/config/env-dev.env b/config/env-dev.env index a66d513..77507d3 100644 --- a/config/env-dev.env +++ b/config/env-dev.env @@ -2,5 +2,5 @@ # Consumed by: Vite build (title) + SPA runtime (getApiBaseUrl / getAppName) # Auth and secrets live on the gateway — never in frontend env. -VITE_API_BASE_URL="http://localhost:8000/" +VITE_API_BASE_URL="http://localhost:8000" VITE_APP_NAME=PowerOn Nyla dev diff --git a/src/api/connectionApi.ts b/src/api/connectionApi.ts index d482948..0322c66 100644 --- a/src/api/connectionApi.ts +++ b/src/api/connectionApi.ts @@ -373,24 +373,9 @@ export async function getDataSourceCostEstimate( }); } -export interface PatchFlagResponse { - sourceId: string; - resetDescendantIds: string[]; - updatedAncestors: { id: string; [key: string]: any }[]; - [key: string]: any; -} - -export async function patchDataSourceRagIndex( - request: ApiRequestFunction, - dataSourceId: string, - ragIndexEnabled: boolean | null -): Promise { - return await request({ - url: `/api/datasources/${dataSourceId}/rag-index`, - method: 'patch', - data: { ragIndexEnabled } - }); -} +// Flag toggles (neutralize / scope / ragIndexEnabled) now go through the +// generic UDB endpoint POST /api/udb/node/{key}/flag/{flag}; see +// `UdbSourcesProvider` and the wiki UDB reference page. // ============================================================================ // RAG INVENTORY diff --git a/src/components/FormGenerator/FormGeneratorTree/FormGeneratorTree.tsx b/src/components/FormGenerator/FormGeneratorTree/FormGeneratorTree.tsx index 7582787..1b9aca7 100644 --- a/src/components/FormGenerator/FormGeneratorTree/FormGeneratorTree.tsx +++ b/src/components/FormGenerator/FormGeneratorTree/FormGeneratorTree.tsx @@ -568,7 +568,6 @@ export function FormGeneratorTree({ onSendToChat, allowCreateFolder = true, selectable = true, - refreshAfterAction = false, className, embedMaxHeight, hideRowActionButtons = false, @@ -614,48 +613,28 @@ export function FormGeneratorTree({ ); - /** After a toggle, collect all currently visible node IDs and ask the - * provider for their updated attributes. Patches only attribute fields - * (neutralize, scope, ragIndexEnabled) on existing nodes — no structural - * reload. Falls back to full refetch if provider doesn't implement - * refreshAttributes. */ + /** After a toggle, refetch children for root + all expanded parents so the + * backend-authoritative effective flag values are current. No attribute-only + * shortcut — the backend is the single source of truth (spec 2026-05-18). */ const _refreshVisibleAttributes = useCallback(async () => { - if (provider.refreshAttributes) { - const visibleIds = flatEntriesRef.current.map((e) => e.node.id); - if (visibleIds.length === 0) return; - const attrs = await provider.refreshAttributes(visibleIds); - setNodes((prev) => - prev.map((n) => { - const update = attrs.get(n.id); - if (!update) return n; - const patched: Partial = {}; - if (n.neutralize !== undefined && update.neutralize !== undefined) patched.neutralize = update.neutralize; - if (n.scope !== undefined && update.scope !== undefined) patched.scope = update.scope; - if (n.ragIndexEnabled !== undefined && update.ragIndexEnabled !== undefined) patched.ragIndexEnabled = update.ragIndexEnabled; - if (Object.keys(patched).length === 0) return n; - return { ...n, ...patched }; - }), - ); - } else { - const expandedList: (string | null)[] = [null, ...Array.from(expandedIds)]; - const fetched = await Promise.all( - expandedList.map((p) => provider.loadChildren(p, ownership)), - ); - const refetchedParents = new Set(expandedList.map((p) => p ?? '__null__')); - setNodes((prev) => { - const keepers = prev.filter((n) => { - const key = n.parentId ?? '__null__'; - return !refetchedParents.has(key); - }); - return [...keepers, ...fetched.flat()]; + const expandedList: (string | null)[] = [null, ...Array.from(expandedIds)]; + const fetched = await Promise.all( + expandedList.map((p) => provider.loadChildren(p, ownership)), + ); + const refetchedParents = new Set(expandedList.map((p) => p ?? '__null__')); + setNodes((prev) => { + const keepers = prev.filter((n) => { + const key = n.parentId ?? '__null__'; + return !refetchedParents.has(key); }); - } + return [...keepers, ...fetched.flat()]; + }); }, [expandedIds, provider, ownership]); /** Wrap any async action with pending-state tracking so the tree can show * a spinner over the corresponding button. Generic — no domain knowledge. - * When `refreshAfterAction` is enabled, the spinner stays on until the - * refreshed attributes have been written into state. */ + * Always refetches all expanded parents after the action completes so the + * backend-authoritative values are rendered. */ const _runAction = useCallback( async (nodeId: string, actionKey: string, fn: () => Promise | void) => { setPendingActions((prev) => { @@ -667,9 +646,7 @@ export function FormGeneratorTree({ }); try { await fn(); - if (refreshAfterAction || provider.refreshAttributes) { - await _refreshVisibleAttributes(); - } + await _refreshVisibleAttributes(); } finally { setPendingActions((prev) => { const next = new Map(prev); @@ -681,7 +658,7 @@ export function FormGeneratorTree({ }); } }, - [refreshAfterAction, _refreshVisibleAttributes], + [_refreshVisibleAttributes], ); const _loadRoot = useCallback(async () => { diff --git a/src/components/FormGenerator/FormGeneratorTree/__tests__/FormGeneratorTree.test.tsx b/src/components/FormGenerator/FormGeneratorTree/__tests__/FormGeneratorTree.test.tsx index e1ad065..b5f9093 100644 --- a/src/components/FormGenerator/FormGeneratorTree/__tests__/FormGeneratorTree.test.tsx +++ b/src/components/FormGenerator/FormGeneratorTree/__tests__/FormGeneratorTree.test.tsx @@ -1106,19 +1106,15 @@ describe('FormGeneratorTree', () => { }); // --------------------------------------------------------------------------- - // refreshAfterAction (backend-authoritative mode) + // Always refetch after action (backend-authoritative, spec 2026-05-18) // --------------------------------------------------------------------------- - describe('refreshAfterAction', () => { + describe('refetch after action', () => { it('refetches null + expanded parents after a flag toggle', async () => { const user = userEvent.setup(); const provider = _createMockProvider([_ownFolder]); render( - , + , ); await waitFor(() => { @@ -1139,28 +1135,6 @@ describe('FormGeneratorTree', () => { expect(newCalls.length).toBeGreaterThan(initialLoadCalls); expect(newCalls.some(c => c[0] === null && c[1] === 'own')).toBe(true); }); - - it('does NOT refetch when refreshAfterAction is false (default)', async () => { - const user = userEvent.setup(); - const provider = _createMockProvider([_ownFolder]); - render(); - - await waitFor(() => { - expect(screen.getByText('My Folder')).toBeInTheDocument(); - }); - - const initialLoadCalls = (provider.loadChildren as ReturnType).mock.calls.length; - - const neutralizeBtn = screen.getByTitle('Nicht neutralisiert'); - await user.click(neutralizeBtn); - - await waitFor(() => { - expect(provider.patchNeutralize).toHaveBeenCalled(); - }); - - const newCalls = (provider.loadChildren as ReturnType).mock.calls.length; - expect(newCalls).toBe(initialLoadCalls); - }); }); // --------------------------------------------------------------------------- diff --git a/src/components/FormGenerator/FormGeneratorTree/providers/FolderFileProvider.tsx b/src/components/FormGenerator/FormGeneratorTree/providers/FolderFileProvider.tsx index 58f800d..f983a84 100644 --- a/src/components/FormGenerator/FormGeneratorTree/providers/FolderFileProvider.tsx +++ b/src/components/FormGenerator/FormGeneratorTree/providers/FolderFileProvider.tsx @@ -299,19 +299,6 @@ export function createFolderFileProvider(options: { includeFiles?: boolean } = { ); }, - async refreshAttributes(ids: string[]) { - const res = await api.post('/api/files/attributes', { ids }); - const raw: Record = res.data ?? {}; - const result = new Map(); - for (const [id, attrs] of Object.entries(raw)) { - result.set(id, { - neutralize: attrs.neutralize, - scope: attrs.scope as ScopeValue | 'mixed', - }); - } - return result; - }, - getBatchActions(): TreeBatchAction[] { return [ { diff --git a/src/components/FormGenerator/FormGeneratorTree/types.ts b/src/components/FormGenerator/FormGeneratorTree/types.ts index 39c34d6..b44cfd0 100644 --- a/src/components/FormGenerator/FormGeneratorTree/types.ts +++ b/src/components/FormGenerator/FormGeneratorTree/types.ts @@ -88,16 +88,6 @@ export interface TreeNodeProvider { patchRagIndex?(ids: string[], ragIndexEnabled: boolean): Promise; downloadNode?(node: TreeNode): Promise; getBatchActions?(): TreeBatchAction[]; - /** After a toggle action, the tree collects all currently visible node IDs - * and calls this method. The provider asks the backend for the current - * attribute values (incl. mixed) of exactly those IDs. The tree then - * patches only the attribute fields on existing nodes — no structural - * reload. If not implemented, the tree falls back to _refetchAllExpanded. */ - refreshAttributes?(ids: string[]): Promise>; /** Called during drag-start to let the provider inject domain-specific MIME * types into the DataTransfer (e.g. `application/datasource`). The generic * tree always sets `application/tree-items` and `text/plain`; this hook @@ -123,14 +113,6 @@ export interface FormGeneratorTreeProps { /** When false, hides checkboxes, multi-select keyboard bindings and the * batch-action toolbar. Default true (backward compatible). */ selectable?: boolean; - /** When true, after every flag-toggle / extra-action the tree refetches - * children for `null` and every currently expanded id, then atomically - * replaces the affected nodes. Optimistic local-state updates are skipped - * in this mode -- the backend is the single source of truth. - * - * Default `false` for backward-compat with FilesTab and other consumers - * that rely on the optimistic-update path. */ - refreshAfterAction?: boolean; className?: string; /** Embedded pickers (e.g. automation node config): constrain overall height so the tree scrolls inside. */ embedMaxHeight?: number; diff --git a/src/components/UnifiedDataBar/FilesTab.tsx b/src/components/UnifiedDataBar/FilesTab.tsx index 99697b7..5a13127 100644 --- a/src/components/UnifiedDataBar/FilesTab.tsx +++ b/src/components/UnifiedDataBar/FilesTab.tsx @@ -201,7 +201,6 @@ const FilesTab: React.FC = ({ context, onFileSelect, onSendToChat title={t('Eigene')} compact={true} showFilter={true} - refreshAfterAction onNodeClick={_handleNodeClickWithImport} onSendToChat={_handleSendToChat} /> @@ -213,7 +212,6 @@ const FilesTab: React.FC = ({ context, onFileSelect, onSendToChat compact={true} collapsible={true} defaultCollapsed={true} - refreshAfterAction emptyMessage={t('Keine geteilten Dateien')} onNodeClick={_handleNodeClickWithImport} onSendToChat={_handleSendToChat} diff --git a/src/components/UnifiedDataBar/SourcesTab.tsx b/src/components/UnifiedDataBar/SourcesTab.tsx index d388f55..dc2af8e 100644 --- a/src/components/UnifiedDataBar/SourcesTab.tsx +++ b/src/components/UnifiedDataBar/SourcesTab.tsx @@ -4,12 +4,12 @@ * SourcesTab — UDB tab for personal connections + mandate data. * * Architecture: - * - Backend is the single source of truth (`POST /api/workspace/{instanceId}/tree/children`). + * - Backend is the single source of truth (`POST /api/udb/tree/children`). * - Tree mechanism: generic `FormGeneratorTree` with a UDB-specific provider. * - Inheritance, mixed-state aggregation and cascade-NULL on patch are * ALL handled by the backend; the frontend never recomputes effective values. - * - Every flag toggle goes through `refreshAfterAction`: PATCH -> refetch all - * expanded parents -> atomic state replace. No optimistic updates. + * - Every flag toggle: PATCH -> refetch all expanded parents via + * loadChildren -> atomic state replace. No optimistic updates. */ import React, { useCallback, useMemo, useState } from 'react'; @@ -67,7 +67,6 @@ const SourcesTab: React.FC = ({ context }) => { compact selectable={false} allowCreateFolder={false} - refreshAfterAction emptyMessage={t('Keine Datenquellen.')} /> diff --git a/src/components/UnifiedDataBar/UdbSourcesProvider.tsx b/src/components/UnifiedDataBar/UdbSourcesProvider.tsx index 4d2abfd..86e399d 100644 --- a/src/components/UnifiedDataBar/UdbSourcesProvider.tsx +++ b/src/components/UnifiedDataBar/UdbSourcesProvider.tsx @@ -3,19 +3,28 @@ /** * UdbSourcesProvider — TreeNodeProvider for the UDB Sources tab. * - * Single responsibility: translate the backend tree contract - * (POST /api/workspace/{instanceId}/tree/children → nodesByParent map) into - * the generic TreeNode shape that FormGeneratorTree consumes, and forward - * flag PATCHes to the existing /api/datasources/{id}/{flag} endpoints. + * 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 PATCHes - * can resolve the implicit DataSource record (creating it lazily when the - * backend reports `canBeAdded=true`), + * 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'; @@ -137,6 +146,11 @@ 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 // --------------------------------------------------------------------------- @@ -165,7 +179,14 @@ function _mapBackendNode( // 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) { @@ -201,11 +222,12 @@ export function createUdbSourcesProvider( instanceId: string, onOpenSettings: (dataSourceId: string, label: string) => void, ): UdbSourcesProviderHandle { - // Per-id cache of the most recent backend payload. Updated by every - // `loadChildren` call. Read by patch/ensureRecord paths. + // 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(); - async function _ensureRecord(node: UdbBackendNode): Promise { + async function _ensureRecordForSettings(node: UdbBackendNode): Promise { if (node.dataSourceId) return node.dataSourceId; try { if (node.kind === 'connection' || node.kind === 'service' @@ -244,13 +266,13 @@ export function createUdbSourcesProvider( return newId; } } catch (err) { - console.error('[UdbSourcesProvider] ensureRecord failed', err); + console.error('[UdbSourcesProvider] ensureRecordForSettings failed', err); } return null; } async function _onSettingsClick(node: UdbBackendNode): Promise { - const dsId = await _ensureRecord(node); + const dsId = await _ensureRecordForSettings(node); if (!dsId) { console.warn('[UdbSourcesProvider] settings click: cannot ensure record', node.key); return; @@ -258,79 +280,19 @@ export function createUdbSourcesProvider( onOpenSettings(dsId, node.label); } - /** fdsField-specific neutralize: ensure the parent fdsTable record exists, - * read its current `neutralizeFields` list, add or remove the field, - * PATCH the new list back. Backend treats the FDS-record as the single - * source of truth for per-field neutralization. */ - async function _patchFieldNeutralize(fieldNodeId: string, neutralize: boolean): Promise { - const fieldNode = nodeCache.get(fieldNodeId); - if (!fieldNode || fieldNode.kind !== 'fdsField') { - console.warn('[UdbSourcesProvider] field-neutralize target missing', fieldNodeId); - return; - } - const fieldName = fieldNode.fieldName; - const featureInstanceId = fieldNode.featureInstanceId; - const tableName = fieldNode.tableName; - if (!fieldName || !featureInstanceId || !tableName) { - console.warn('[UdbSourcesProvider] field-neutralize missing context', fieldNode); - return; - } - // Resolve the parent fdsTable record. Use the node's dataSourceId if - // already known (synthesized by the backend); otherwise create the - // record via _ensureRecord on a synthetic table-shaped node. - let dsId = fieldNode.dataSourceId; - if (!dsId) { - const tableNode: UdbBackendNode = { - ...fieldNode, - kind: 'fdsTable', - key: `fdstbl|${featureInstanceId}|${tableName}`, - }; - dsId = await _ensureRecord(tableNode); - } - if (!dsId) return; - // The parent fdsTable node carries `neutralizeFields` in its payload; - // pull it from the cache. Falls back to the field's effective state if - // the parent isn't cached for some reason. - const tableKey = `fdstbl|${featureInstanceId}|${tableName}`; - const tableNode = nodeCache.get(tableKey); - const currentList: string[] = - tableNode && Array.isArray(tableNode.neutralizeFields) - ? [...tableNode.neutralizeFields] - : []; - const set = new Set(currentList); - if (neutralize) set.add(fieldName); - else set.delete(fieldName); - const newList = Array.from(set); - try { - await api.patch(`/api/datasources/${dsId}/neutralize-fields`, { neutralizeFields: newList }); - // Keep the cache in sync so subsequent toggles in the same session - // start from the right baseline. - if (tableNode) { - nodeCache.set(tableKey, { ...tableNode, neutralizeFields: newList }); - } - } catch (err) { - console.error('[UdbSourcesProvider] patch neutralize-fields failed', { fieldNodeId, err }); - throw err; - } - } - + /** 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: 'scope' | 'neutralize' | 'rag-index', - body: Record, + flag: 'neutralize' | 'scope' | 'ragIndexEnabled', + value: unknown, ): Promise { - for (const id of ids) { - const cached = nodeCache.get(id); - if (!cached) { - console.warn('[UdbSourcesProvider] patch target not in cache', id); - continue; - } - const dsId = await _ensureRecord(cached); - if (!dsId) continue; + for (const nodeKey of ids) { try { - await api.patch(`/api/datasources/${dsId}/${flag}`, body); + await api.post(`/api/udb/node/${encodeURIComponent(nodeKey)}/flag/${flag}`, { value }); } catch (err) { - console.error('[UdbSourcesProvider] patch failed', { id, flag, err }); + console.error('[UdbSourcesProvider] patch failed', { nodeKey, flag, err }); throw err; } } @@ -340,7 +302,7 @@ export function createUdbSourcesProvider( rootKey: `udb-sources-${instanceId}`, async loadChildren(parentId, _ownership) { - const res = await api.post(`/api/workspace/${instanceId}/tree/children`, { + const res = await api.post(`/api/udb/tree/children`, { parents: [parentId], }); const nodesByParent = res.data?.nodesByParent || {}; @@ -352,8 +314,8 @@ export function createUdbSourcesProvider( canPatchScope(node) { const data = node.data; - // Field-level scope makes no sense; it's inherited from the parent table. - return !!data && !_isSyntheticContainer(data.kind) && data.kind !== 'fdsField'; + // Scope only exists on DataSource family; FDS / synthetic containers / fields hide it. + return !!data && !_isSyntheticContainer(data.kind) && !_isFdsKind(data.kind); }, canPatchNeutralize(node) { @@ -363,7 +325,7 @@ export function createUdbSourcesProvider( canPatchRagIndex(node) { const data = node.data; - // RAG is not a field-level concept either; only the table-record carries it. + // RAG exists at the data-source level (DS root / FDS table+rows), never on fields. return !!data && data.supportsRag === true && data.kind !== 'fdsField'; }, @@ -371,26 +333,15 @@ export function createUdbSourcesProvider( // 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 }); + await _patchFlag(ids, 'scope', scope); }, async patchNeutralize(ids, neutralize) { - // fdsField nodes don't have their own DB record — they are addressed - // via the parent fdsTable's `neutralizeFields` array. Split the batch - // accordingly and dispatch each kind to the right endpoint. - const fieldIds: string[] = []; - const otherIds: string[] = []; - for (const id of ids) { - const cached = nodeCache.get(id); - if (cached?.kind === 'fdsField') fieldIds.push(id); - else otherIds.push(id); - } - if (otherIds.length > 0) await _patchFlag(otherIds, 'neutralize', { neutralize }); - for (const fieldId of fieldIds) await _patchFieldNeutralize(fieldId, neutralize); + await _patchFlag(ids, 'neutralize', neutralize); }, async patchRagIndex(ids, ragIndexEnabled) { - await _patchFlag(ids, 'rag-index', { ragIndexEnabled }); + await _patchFlag(ids, 'ragIndexEnabled', ragIndexEnabled); }, customizeDragData(node, dataTransfer) { @@ -424,30 +375,6 @@ export function createUdbSourcesProvider( } }, - async refreshAttributes(ids: string[]) { - const res = await api.post(`/api/workspace/${instanceId}/tree/attributes`, { - keys: ids, - }); - const raw: Record = res.data?.attributes ?? {}; - const result = new Map(); - for (const [key, attrs] of Object.entries(raw)) { - result.set(key, { - neutralize: attrs.effectiveNeutralize, - scope: attrs.effectiveScope as ScopeValue | 'mixed', - ragIndexEnabled: attrs.effectiveRagIndexEnabled, - }); - } - return result; - }, - _diagnosticGetCacheSize() { return nodeCache.size; }, diff --git a/src/components/UnifiedDataBar/__tests__/UdbSourcesProvider.test.ts b/src/components/UnifiedDataBar/__tests__/UdbSourcesProvider.test.ts index 8fb974e..d878648 100644 --- a/src/components/UnifiedDataBar/__tests__/UdbSourcesProvider.test.ts +++ b/src/components/UnifiedDataBar/__tests__/UdbSourcesProvider.test.ts @@ -60,6 +60,50 @@ function _makeSynthRootNode(): UdbBackendNode { }; } +function _makeFdsTableNode(): UdbBackendNode { + return { + key: 'fdstbl|fi1|Kontakte', + kind: 'fdsTable', + parentKey: 'feat|m1|trustee|fi1', + label: 'Kontakte', + icon: 'table', + hasChildren: true, + dataSourceId: 'fds-1', + modelType: 'FeatureDataSource', + effectiveNeutralize: false, + effectiveScope: 'personal', + effectiveRagIndexEnabled: false, + supportsRag: true, + canBeAdded: false, + featureInstanceId: 'fi1', + featureCode: 'trustee', + tableName: 'Kontakte', + objectKey: 'data.feature.trustee.Kontakte', + neutralizeFields: [], + }; +} + +function _makeFdsFieldNode(): UdbBackendNode { + return { + key: 'fdsfld|fi1|Kontakte|email', + kind: 'fdsField', + parentKey: 'fdstbl|fi1|Kontakte', + label: 'email', + icon: 'field', + hasChildren: false, + dataSourceId: 'fds-1', + modelType: 'FeatureDataSource', + effectiveNeutralize: false, + effectiveScope: 'personal', + effectiveRagIndexEnabled: false, + supportsRag: false, + canBeAdded: false, + featureInstanceId: 'fi1', + tableName: 'Kontakte', + fieldName: 'email', + }; +} + const _instanceId = 'inst-42'; beforeEach(() => { @@ -68,23 +112,23 @@ beforeEach(() => { }); // --------------------------------------------------------------------------- -// loadChildren +// loadChildren -> POST /api/udb/tree/children (feature-agnostic) // --------------------------------------------------------------------------- describe('UdbSourcesProvider.loadChildren', () => { - it('calls POST /api/workspace/{instanceId}/tree/children with parents=[parentId]', async () => { + it('calls POST /api/udb/tree/children with parents=[parentId]', async () => { apiMock.post.mockResolvedValue({ data: { nodesByParent: { __root__: [] } } }); const provider = createUdbSourcesProvider(_instanceId, vi.fn()); await provider.loadChildren(null, 'own'); expect(apiMock.post).toHaveBeenCalledWith( - `/api/workspace/${_instanceId}/tree/children`, + `/api/udb/tree/children`, { parents: [null] }, ); }); - it('maps backend nodes to TreeNode shape with flag-bearer fields', async () => { + it('maps DS-family backend nodes to TreeNode shape with all three flags', async () => { const conn = _makeBackendNode(); apiMock.post.mockResolvedValue({ data: { nodesByParent: { 'personalRoot': [conn] } } }); const provider = createUdbSourcesProvider(_instanceId, vi.fn()); @@ -94,103 +138,42 @@ describe('UdbSourcesProvider.loadChildren', () => { expect(result).toHaveLength(1); const tn = result[0]; expect(tn.id).toBe('conn|c1'); - expect(tn.name).toBe('My Microsoft'); - expect(tn.parentId).toBe('personalRoot'); - expect(tn.ownership).toBe('own'); expect(tn.scope).toBe('personal'); expect(tn.neutralize).toBe(false); expect(tn.ragIndexEnabled).toBe(false); - expect(tn.type).toBe('folder'); }); - it('hides scope/neutralize/ragIndexEnabled on synthetic containers', async () => { + it('FDS table maps to only 2 flags (no scope)', async () => { + const tbl = _makeFdsTableNode(); + apiMock.post.mockResolvedValue({ data: { nodesByParent: { 'feat|m1|trustee|fi1': [tbl] } } }); + const provider = createUdbSourcesProvider(_instanceId, vi.fn()); + + const [tn] = await provider.loadChildren('feat|m1|trustee|fi1', 'own'); + expect(tn.neutralize).toBe(false); + expect(tn.ragIndexEnabled).toBe(false); + expect(tn.scope).toBeUndefined(); + }); + + it('FDS field maps to only neutralize (no scope, no rag)', async () => { + const fld = _makeFdsFieldNode(); + apiMock.post.mockResolvedValue({ data: { nodesByParent: { 'fdstbl|fi1|Kontakte': [fld] } } }); + const provider = createUdbSourcesProvider(_instanceId, vi.fn()); + + const [tn] = await provider.loadChildren('fdstbl|fi1|Kontakte', 'own'); + expect(tn.neutralize).toBe(false); + expect(tn.scope).toBeUndefined(); + expect(tn.ragIndexEnabled).toBeUndefined(); + }); + + it('hides all flags on synthetic containers', async () => { const root = _makeSynthRootNode(); apiMock.post.mockResolvedValue({ data: { nodesByParent: { __root__: [root] } } }); const provider = createUdbSourcesProvider(_instanceId, vi.fn()); - const result = await provider.loadChildren(null, 'own'); - - expect(result).toHaveLength(1); - expect(result[0].scope).toBeUndefined(); - expect(result[0].neutralize).toBeUndefined(); - expect(result[0].ragIndexEnabled).toBeUndefined(); - expect(result[0].displayOrder).toBe(0); - }); - - it('omits ragIndexEnabled when supportsRag is false', async () => { - const node = _makeBackendNode({ - key: 'mgrp|m1', - kind: 'mandateGroup', - parentKey: null, - supportsRag: false, - }); - apiMock.post.mockResolvedValue({ data: { nodesByParent: { __root__: [node] } } }); - const provider = createUdbSourcesProvider(_instanceId, vi.fn()); - - const result = await provider.loadChildren(null, 'own'); - - expect(result[0].ragIndexEnabled).toBeUndefined(); - expect(result[0].scope).toBeUndefined(); - expect(result[0].neutralize).toBeUndefined(); - }); - - it('attaches the settings extraAction on every data-source-root, even without a record yet', async () => { - const onSettings = vi.fn(); - const withId = _makeBackendNode({ dataSourceId: 'ds-1', canBeAdded: false }); - const withoutId = _makeBackendNode({ key: 'conn|c2', dataSourceId: null }); - apiMock.post.mockResolvedValue({ - data: { nodesByParent: { personalRoot: [withId, withoutId] } }, - }); - const provider = createUdbSourcesProvider(_instanceId, onSettings); - - const result = await provider.loadChildren('personalRoot', 'own'); - - expect(result[0].extraActions).toHaveLength(1); - expect(result[0].extraActions?.[0].key).toBe('settings'); - await result[0].extraActions?.[0].onClick?.(); - expect(onSettings).toHaveBeenCalledWith('ds-1', 'My Microsoft'); - - // The conn without a record still gets a settings button (always visible - // on data-source-roots). Click triggers an _ensureRecord POST first. - expect(result[1].extraActions).toHaveLength(1); - expect(result[1].extraActions?.[0].key).toBe('settings'); - }); - - it('hides the settings extraAction on non-root nodes (folders, files, services, ...)', async () => { - const folder = _makeBackendNode({ kind: 'folder', dataSourceId: 'ds-9' }); - apiMock.post.mockResolvedValue({ - data: { nodesByParent: { 'conn|c1': [folder] } }, - }); - const provider = createUdbSourcesProvider(_instanceId, vi.fn()); - - const result = await provider.loadChildren('conn|c1', 'own'); - expect(result[0].extraActions).toBeUndefined(); - }); - - it('forwards defaultExpanded from backend payload to the TreeNode', async () => { - const expanded = _makeBackendNode({ - key: 'personalRoot', - kind: 'synthRoot', - defaultExpanded: true, - }); - apiMock.post.mockResolvedValue({ - data: { nodesByParent: { __root__: [expanded] } }, - }); - const provider = createUdbSourcesProvider(_instanceId, vi.fn()); - - const [node] = await provider.loadChildren(null, 'own'); - expect(node.defaultExpanded).toBe(true); - }); - - it('populates the internal cache so subsequent patches can resolve nodes', async () => { - apiMock.post.mockResolvedValue({ - data: { nodesByParent: { personalRoot: [_makeBackendNode()] } }, - }); - const provider = createUdbSourcesProvider(_instanceId, vi.fn()); - - expect(provider._diagnosticGetCacheSize()).toBe(0); - await provider.loadChildren('personalRoot', 'own'); - expect(provider._diagnosticGetCacheSize()).toBe(1); + const [tn] = await provider.loadChildren(null, 'own'); + expect(tn.scope).toBeUndefined(); + expect(tn.neutralize).toBeUndefined(); + expect(tn.ragIndexEnabled).toBeUndefined(); }); }); @@ -199,7 +182,7 @@ describe('UdbSourcesProvider.loadChildren', () => { // --------------------------------------------------------------------------- describe('UdbSourcesProvider.canPatch*', () => { - it('canPatchScope is false for synthetic containers', async () => { + it('canPatch* all false for synthetic containers', async () => { apiMock.post.mockResolvedValue({ data: { nodesByParent: { __root__: [_makeSynthRootNode()] } }, }); @@ -210,175 +193,84 @@ describe('UdbSourcesProvider.canPatch*', () => { expect(provider.canPatchRagIndex?.(synthNode)).toBe(false); }); - it('canPatchRagIndex requires supportsRag=true', async () => { + it('canPatchScope is false for any FDS kind', async () => { apiMock.post.mockResolvedValue({ - data: { - nodesByParent: { - personalRoot: [ - _makeBackendNode({ key: 'a', supportsRag: true }), - _makeBackendNode({ key: 'b', supportsRag: false }), - ], - }, - }, + data: { nodesByParent: { 'feat|m1|trustee|fi1': [_makeFdsTableNode()] } }, }); const provider = createUdbSourcesProvider(_instanceId, vi.fn()); - const [a, b] = await provider.loadChildren('personalRoot', 'own'); - expect(provider.canPatchRagIndex?.(a)).toBe(true); - expect(provider.canPatchRagIndex?.(b)).toBe(false); + const [tbl] = await provider.loadChildren('feat|m1|trustee|fi1', 'own'); + expect(provider.canPatchScope?.(tbl)).toBe(false); + expect(provider.canPatchNeutralize?.(tbl)).toBe(true); + expect(provider.canPatchRagIndex?.(tbl)).toBe(true); + }); + + it('canPatchRagIndex is false on fdsField (only neutralize at field level)', async () => { + apiMock.post.mockResolvedValue({ + data: { nodesByParent: { 'fdstbl|fi1|Kontakte': [_makeFdsFieldNode()] } }, + }); + const provider = createUdbSourcesProvider(_instanceId, vi.fn()); + const [fld] = await provider.loadChildren('fdstbl|fi1|Kontakte', 'own'); + expect(provider.canPatchNeutralize?.(fld)).toBe(true); + expect(provider.canPatchRagIndex?.(fld)).toBe(false); + expect(provider.canPatchScope?.(fld)).toBe(false); }); }); // --------------------------------------------------------------------------- -// patch flow: ensureRecord + PATCH +// patch flow -> POST /api/udb/node/{key}/flag/{flag} // --------------------------------------------------------------------------- describe('UdbSourcesProvider.patchScope', () => { - it('PATCHes existing dataSourceId without creating a new record', async () => { - apiMock.post.mockResolvedValueOnce({ - data: { - nodesByParent: { - personalRoot: [_makeBackendNode({ dataSourceId: 'ds-existing', canBeAdded: false })], - }, - }, - }); - apiMock.patch.mockResolvedValue({ data: {} }); + it('POSTs to /api/udb/node/{key}/flag/scope with the new value', async () => { + apiMock.post.mockResolvedValue({ data: {} }); const provider = createUdbSourcesProvider(_instanceId, vi.fn()); - await provider.loadChildren('personalRoot', 'own'); await provider.patchScope?.(['conn|c1'], 'mandate', true); - expect(apiMock.patch).toHaveBeenCalledWith( - `/api/datasources/ds-existing/scope`, - { scope: 'mandate' }, + expect(apiMock.post).toHaveBeenCalledWith( + `/api/udb/node/${encodeURIComponent('conn|c1')}/flag/scope`, + { value: 'mandate' }, ); - // Only one POST: the loadChildren call. No POST datasources. - expect(apiMock.post).toHaveBeenCalledTimes(1); - }); - - it('creates a DataSource record first when canBeAdded=true', async () => { - apiMock.post - .mockResolvedValueOnce({ - data: { - nodesByParent: { - personalRoot: [_makeBackendNode({ dataSourceId: null, canBeAdded: true })], - }, - }, - }) - .mockResolvedValueOnce({ data: { id: 'ds-new' } }); - apiMock.patch.mockResolvedValue({ data: {} }); - const provider = createUdbSourcesProvider(_instanceId, vi.fn()); - await provider.loadChildren('personalRoot', 'own'); - - await provider.patchScope?.(['conn|c1'], 'mandate', true); - - expect(apiMock.post).toHaveBeenNthCalledWith( - 2, - `/api/workspace/${_instanceId}/datasources`, - expect.objectContaining({ - connectionId: 'c1', - sourceType: 'msft', - path: '/', - label: 'My Microsoft', - }), - ); - expect(apiMock.patch).toHaveBeenCalledWith( - `/api/datasources/ds-new/scope`, - { scope: 'mandate' }, - ); - }); - - it('skips silently when target node is not in cache', async () => { - const provider = createUdbSourcesProvider(_instanceId, vi.fn()); - await provider.patchScope?.(['unknown'], 'personal', false); - expect(apiMock.patch).not.toHaveBeenCalled(); }); }); describe('UdbSourcesProvider.patchNeutralize', () => { - it('PATCHes /neutralize with the supplied boolean', async () => { - apiMock.post.mockResolvedValueOnce({ - data: { - nodesByParent: { - personalRoot: [_makeBackendNode({ dataSourceId: 'ds-1', canBeAdded: false })], - }, - }, - }); - apiMock.patch.mockResolvedValue({ data: {} }); + it('POSTs to /api/udb/node/{key}/flag/neutralize', async () => { + apiMock.post.mockResolvedValue({ data: {} }); const provider = createUdbSourcesProvider(_instanceId, vi.fn()); - await provider.loadChildren('personalRoot', 'own'); await provider.patchNeutralize?.(['conn|c1'], true); - expect(apiMock.patch).toHaveBeenCalledWith( - `/api/datasources/ds-1/neutralize`, - { neutralize: true }, + expect(apiMock.post).toHaveBeenCalledWith( + `/api/udb/node/${encodeURIComponent('conn|c1')}/flag/neutralize`, + { value: true }, + ); + }); + + it('uses the same generic endpoint for FDS field nodes (no kind-split)', async () => { + apiMock.post.mockResolvedValue({ data: {} }); + const provider = createUdbSourcesProvider(_instanceId, vi.fn()); + + await provider.patchNeutralize?.(['fdsfld|fi1|Kontakte|email'], true); + + expect(apiMock.post).toHaveBeenCalledTimes(1); + expect(apiMock.post).toHaveBeenCalledWith( + `/api/udb/node/${encodeURIComponent('fdsfld|fi1|Kontakte|email')}/flag/neutralize`, + { value: true }, ); }); }); describe('UdbSourcesProvider.patchRagIndex', () => { - it('PATCHes /rag-index with the supplied boolean (note dash in URL, camelCase in body)', async () => { - apiMock.post.mockResolvedValueOnce({ - data: { - nodesByParent: { - personalRoot: [_makeBackendNode({ dataSourceId: 'ds-1', canBeAdded: false })], - }, - }, - }); - apiMock.patch.mockResolvedValue({ data: {} }); + it('POSTs to /api/udb/node/{key}/flag/ragIndexEnabled', async () => { + apiMock.post.mockResolvedValue({ data: {} }); const provider = createUdbSourcesProvider(_instanceId, vi.fn()); - await provider.loadChildren('personalRoot', 'own'); await provider.patchRagIndex?.(['conn|c1'], true); - expect(apiMock.patch).toHaveBeenCalledWith( - `/api/datasources/ds-1/rag-index`, - { ragIndexEnabled: true }, - ); - }); - - it('routes to feature-datasources when the cached node is a featureNode', async () => { - const featureNode: UdbBackendNode = { - key: 'feat|m1|trustee|inst-1', - kind: 'featureNode', - parentKey: 'mgrp|m1', - label: 'Trustee', - icon: 'mdi-database', - hasChildren: true, - dataSourceId: null, - modelType: null, - effectiveNeutralize: false, - effectiveScope: 'personal', - effectiveRagIndexEnabled: false, - supportsRag: true, - canBeAdded: true, - featureInstanceId: 'inst-1', - featureCode: 'trustee', - mandateId: 'm1', - tableName: '*', - }; - apiMock.post - .mockResolvedValueOnce({ data: { nodesByParent: { 'mgrp|m1': [featureNode] } } }) - .mockResolvedValueOnce({ data: { id: 'fds-new' } }); - apiMock.patch.mockResolvedValue({ data: {} }); - const provider = createUdbSourcesProvider(_instanceId, vi.fn()); - await provider.loadChildren('mgrp|m1', 'own'); - - await provider.patchRagIndex?.([featureNode.key], true); - - expect(apiMock.post).toHaveBeenNthCalledWith( - 2, - `/api/workspace/${_instanceId}/feature-datasources`, - expect.objectContaining({ - featureInstanceId: 'inst-1', - featureCode: 'trustee', - tableName: '*', - objectKey: 'data.feature.trustee.*', - }), - ); - expect(apiMock.patch).toHaveBeenCalledWith( - `/api/datasources/fds-new/rag-index`, - { ragIndexEnabled: true }, + expect(apiMock.post).toHaveBeenCalledWith( + `/api/udb/node/${encodeURIComponent('conn|c1')}/flag/ragIndexEnabled`, + { value: true }, ); }); });