fixes udb
Some checks failed
Deploy Nyla Frontend to Production / deploy (push) Successful in 46s
Deploy Nyla Frontend to Integration / deploy (push) Failing after 2s

This commit is contained in:
ValueOn AG 2026-05-27 16:48:52 +02:00
parent 0331a59da3
commit 639cac2e33
10 changed files with 199 additions and 478 deletions

View file

@ -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

View file

@ -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<PatchFlagResponse> {
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

View file

@ -568,7 +568,6 @@ export function FormGeneratorTree<T = any>({
onSendToChat,
allowCreateFolder = true,
selectable = true,
refreshAfterAction = false,
className,
embedMaxHeight,
hideRowActionButtons = false,
@ -614,48 +613,28 @@ export function FormGeneratorTree<T = any>({
);
/** 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<typeof n> = {};
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> | void) => {
setPendingActions((prev) => {
@ -667,9 +646,7 @@ export function FormGeneratorTree<T = any>({
});
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<T = any>({
});
}
},
[refreshAfterAction, _refreshVisibleAttributes],
[_refreshVisibleAttributes],
);
const _loadRoot = useCallback(async () => {

View file

@ -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(
<FormGeneratorTree
provider={provider}
ownership="own"
refreshAfterAction
/>,
<FormGeneratorTree provider={provider} ownership="own" />,
);
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(<FormGeneratorTree provider={provider} ownership="own" />);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
const initialLoadCalls = (provider.loadChildren as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mock.calls.length;
expect(newCalls).toBe(initialLoadCalls);
});
});
// ---------------------------------------------------------------------------

View file

@ -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<string, { neutralize?: boolean | 'mixed'; scope?: string | 'mixed' }> = res.data ?? {};
const result = new Map<string, { neutralize?: boolean | 'mixed'; scope?: ScopeValue | 'mixed' }>();
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 [
{

View file

@ -88,16 +88,6 @@ export interface TreeNodeProvider<T = any> {
patchRagIndex?(ids: string[], ragIndexEnabled: boolean): Promise<void>;
downloadNode?(node: TreeNode<T>): Promise<void>;
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<Map<string, {
neutralize?: boolean | 'mixed';
scope?: ScopeValue | 'mixed';
ragIndexEnabled?: boolean | 'mixed';
}>>;
/** 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<T = any> {
/** 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;

View file

@ -201,7 +201,6 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
title={t('Eigene')}
compact={true}
showFilter={true}
refreshAfterAction
onNodeClick={_handleNodeClickWithImport}
onSendToChat={_handleSendToChat}
/>
@ -213,7 +212,6 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
compact={true}
collapsible={true}
defaultCollapsed={true}
refreshAfterAction
emptyMessage={t('Keine geteilten Dateien')}
onNodeClick={_handleNodeClickWithImport}
onSendToChat={_handleSendToChat}

View file

@ -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<SourcesTabProps> = ({ context }) => {
compact
selectable={false}
allowCreateFolder={false}
refreshAfterAction
emptyMessage={t('Keine Datenquellen.')}
/>
</div>

View file

@ -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<string, UdbBackendNode>();
async function _ensureRecord(node: UdbBackendNode): Promise<string | null> {
async function _ensureRecordForSettings(node: UdbBackendNode): Promise<string | null> {
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<void> {
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<void> {
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<string, unknown>,
flag: 'neutralize' | 'scope' | 'ragIndexEnabled',
value: unknown,
): Promise<void> {
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<string, {
effectiveNeutralize?: boolean | 'mixed';
effectiveScope?: string | 'mixed';
effectiveRagIndexEnabled?: boolean | 'mixed';
}> = res.data?.attributes ?? {};
const result = new Map<string, {
neutralize?: boolean | 'mixed';
scope?: ScopeValue | 'mixed';
ragIndexEnabled?: boolean | 'mixed';
}>();
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;
},

View file

@ -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 },
);
});
});