fixes udb
This commit is contained in:
parent
0331a59da3
commit
639cac2e33
10 changed files with 199 additions and 478 deletions
|
|
@ -2,5 +2,5 @@
|
||||||
# Consumed by: Vite build (title) + SPA runtime (getApiBaseUrl / getAppName)
|
# Consumed by: Vite build (title) + SPA runtime (getApiBaseUrl / getAppName)
|
||||||
# Auth and secrets live on the gateway — never in frontend env.
|
# 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
|
VITE_APP_NAME=PowerOn Nyla dev
|
||||||
|
|
|
||||||
|
|
@ -373,24 +373,9 @@ export async function getDataSourceCostEstimate(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PatchFlagResponse {
|
// Flag toggles (neutralize / scope / ragIndexEnabled) now go through the
|
||||||
sourceId: string;
|
// generic UDB endpoint POST /api/udb/node/{key}/flag/{flag}; see
|
||||||
resetDescendantIds: string[];
|
// `UdbSourcesProvider` and the wiki UDB reference page.
|
||||||
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 }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// RAG INVENTORY
|
// RAG INVENTORY
|
||||||
|
|
|
||||||
|
|
@ -568,7 +568,6 @@ export function FormGeneratorTree<T = any>({
|
||||||
onSendToChat,
|
onSendToChat,
|
||||||
allowCreateFolder = true,
|
allowCreateFolder = true,
|
||||||
selectable = true,
|
selectable = true,
|
||||||
refreshAfterAction = false,
|
|
||||||
className,
|
className,
|
||||||
embedMaxHeight,
|
embedMaxHeight,
|
||||||
hideRowActionButtons = false,
|
hideRowActionButtons = false,
|
||||||
|
|
@ -614,29 +613,10 @@ export function FormGeneratorTree<T = any>({
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
/** After a toggle, collect all currently visible node IDs and ask the
|
/** After a toggle, refetch children for root + all expanded parents so the
|
||||||
* provider for their updated attributes. Patches only attribute fields
|
* backend-authoritative effective flag values are current. No attribute-only
|
||||||
* (neutralize, scope, ragIndexEnabled) on existing nodes — no structural
|
* shortcut — the backend is the single source of truth (spec 2026-05-18). */
|
||||||
* reload. Falls back to full refetch if provider doesn't implement
|
|
||||||
* refreshAttributes. */
|
|
||||||
const _refreshVisibleAttributes = useCallback(async () => {
|
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 expandedList: (string | null)[] = [null, ...Array.from(expandedIds)];
|
||||||
const fetched = await Promise.all(
|
const fetched = await Promise.all(
|
||||||
expandedList.map((p) => provider.loadChildren(p, ownership)),
|
expandedList.map((p) => provider.loadChildren(p, ownership)),
|
||||||
|
|
@ -649,13 +629,12 @@ export function FormGeneratorTree<T = any>({
|
||||||
});
|
});
|
||||||
return [...keepers, ...fetched.flat()];
|
return [...keepers, ...fetched.flat()];
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}, [expandedIds, provider, ownership]);
|
}, [expandedIds, provider, ownership]);
|
||||||
|
|
||||||
/** Wrap any async action with pending-state tracking so the tree can show
|
/** Wrap any async action with pending-state tracking so the tree can show
|
||||||
* a spinner over the corresponding button. Generic — no domain knowledge.
|
* a spinner over the corresponding button. Generic — no domain knowledge.
|
||||||
* When `refreshAfterAction` is enabled, the spinner stays on until the
|
* Always refetches all expanded parents after the action completes so the
|
||||||
* refreshed attributes have been written into state. */
|
* backend-authoritative values are rendered. */
|
||||||
const _runAction = useCallback(
|
const _runAction = useCallback(
|
||||||
async (nodeId: string, actionKey: string, fn: () => Promise<void> | void) => {
|
async (nodeId: string, actionKey: string, fn: () => Promise<void> | void) => {
|
||||||
setPendingActions((prev) => {
|
setPendingActions((prev) => {
|
||||||
|
|
@ -667,9 +646,7 @@ export function FormGeneratorTree<T = any>({
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await fn();
|
await fn();
|
||||||
if (refreshAfterAction || provider.refreshAttributes) {
|
|
||||||
await _refreshVisibleAttributes();
|
await _refreshVisibleAttributes();
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setPendingActions((prev) => {
|
setPendingActions((prev) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
|
|
@ -681,7 +658,7 @@ export function FormGeneratorTree<T = any>({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[refreshAfterAction, _refreshVisibleAttributes],
|
[_refreshVisibleAttributes],
|
||||||
);
|
);
|
||||||
|
|
||||||
const _loadRoot = useCallback(async () => {
|
const _loadRoot = useCallback(async () => {
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
it('refetches null + expanded parents after a flag toggle', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const provider = _createMockProvider([_ownFolder]);
|
const provider = _createMockProvider([_ownFolder]);
|
||||||
render(
|
render(
|
||||||
<FormGeneratorTree
|
<FormGeneratorTree provider={provider} ownership="own" />,
|
||||||
provider={provider}
|
|
||||||
ownership="own"
|
|
||||||
refreshAfterAction
|
|
||||||
/>,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|
@ -1139,28 +1135,6 @@ describe('FormGeneratorTree', () => {
|
||||||
expect(newCalls.length).toBeGreaterThan(initialLoadCalls);
|
expect(newCalls.length).toBeGreaterThan(initialLoadCalls);
|
||||||
expect(newCalls.some(c => c[0] === null && c[1] === 'own')).toBe(true);
|
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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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[] {
|
getBatchActions(): TreeBatchAction[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -88,16 +88,6 @@ export interface TreeNodeProvider<T = any> {
|
||||||
patchRagIndex?(ids: string[], ragIndexEnabled: boolean): Promise<void>;
|
patchRagIndex?(ids: string[], ragIndexEnabled: boolean): Promise<void>;
|
||||||
downloadNode?(node: TreeNode<T>): Promise<void>;
|
downloadNode?(node: TreeNode<T>): Promise<void>;
|
||||||
getBatchActions?(): TreeBatchAction[];
|
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
|
/** Called during drag-start to let the provider inject domain-specific MIME
|
||||||
* types into the DataTransfer (e.g. `application/datasource`). The generic
|
* types into the DataTransfer (e.g. `application/datasource`). The generic
|
||||||
* tree always sets `application/tree-items` and `text/plain`; this hook
|
* 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
|
/** When false, hides checkboxes, multi-select keyboard bindings and the
|
||||||
* batch-action toolbar. Default true (backward compatible). */
|
* batch-action toolbar. Default true (backward compatible). */
|
||||||
selectable?: boolean;
|
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;
|
className?: string;
|
||||||
/** Embedded pickers (e.g. automation node config): constrain overall height so the tree scrolls inside. */
|
/** Embedded pickers (e.g. automation node config): constrain overall height so the tree scrolls inside. */
|
||||||
embedMaxHeight?: number;
|
embedMaxHeight?: number;
|
||||||
|
|
|
||||||
|
|
@ -201,7 +201,6 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
|
||||||
title={t('Eigene')}
|
title={t('Eigene')}
|
||||||
compact={true}
|
compact={true}
|
||||||
showFilter={true}
|
showFilter={true}
|
||||||
refreshAfterAction
|
|
||||||
onNodeClick={_handleNodeClickWithImport}
|
onNodeClick={_handleNodeClickWithImport}
|
||||||
onSendToChat={_handleSendToChat}
|
onSendToChat={_handleSendToChat}
|
||||||
/>
|
/>
|
||||||
|
|
@ -213,7 +212,6 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
|
||||||
compact={true}
|
compact={true}
|
||||||
collapsible={true}
|
collapsible={true}
|
||||||
defaultCollapsed={true}
|
defaultCollapsed={true}
|
||||||
refreshAfterAction
|
|
||||||
emptyMessage={t('Keine geteilten Dateien')}
|
emptyMessage={t('Keine geteilten Dateien')}
|
||||||
onNodeClick={_handleNodeClickWithImport}
|
onNodeClick={_handleNodeClickWithImport}
|
||||||
onSendToChat={_handleSendToChat}
|
onSendToChat={_handleSendToChat}
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,12 @@
|
||||||
* SourcesTab — UDB tab for personal connections + mandate data.
|
* SourcesTab — UDB tab for personal connections + mandate data.
|
||||||
*
|
*
|
||||||
* Architecture:
|
* 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.
|
* - Tree mechanism: generic `FormGeneratorTree` with a UDB-specific provider.
|
||||||
* - Inheritance, mixed-state aggregation and cascade-NULL on patch are
|
* - Inheritance, mixed-state aggregation and cascade-NULL on patch are
|
||||||
* ALL handled by the backend; the frontend never recomputes effective values.
|
* ALL handled by the backend; the frontend never recomputes effective values.
|
||||||
* - Every flag toggle goes through `refreshAfterAction`: PATCH -> refetch all
|
* - Every flag toggle: PATCH -> refetch all expanded parents via
|
||||||
* expanded parents -> atomic state replace. No optimistic updates.
|
* loadChildren -> atomic state replace. No optimistic updates.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useMemo, useState } from 'react';
|
import React, { useCallback, useMemo, useState } from 'react';
|
||||||
|
|
@ -67,7 +67,6 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context }) => {
|
||||||
compact
|
compact
|
||||||
selectable={false}
|
selectable={false}
|
||||||
allowCreateFolder={false}
|
allowCreateFolder={false}
|
||||||
refreshAfterAction
|
|
||||||
emptyMessage={t('Keine Datenquellen.')}
|
emptyMessage={t('Keine Datenquellen.')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,19 +3,28 @@
|
||||||
/**
|
/**
|
||||||
* UdbSourcesProvider — TreeNodeProvider for the UDB Sources tab.
|
* UdbSourcesProvider — TreeNodeProvider for the UDB Sources tab.
|
||||||
*
|
*
|
||||||
* Single responsibility: translate the backend tree contract
|
* Single responsibility: translate the generic UDB backend tree contract
|
||||||
* (POST /api/workspace/{instanceId}/tree/children → nodesByParent map) into
|
* (POST /api/udb/tree/children -> nodesByParent map) into the generic
|
||||||
* the generic TreeNode shape that FormGeneratorTree consumes, and forward
|
* TreeNode shape that FormGeneratorTree consumes, and forward flag
|
||||||
* flag PATCHes to the existing /api/datasources/{id}/{flag} endpoints.
|
* 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:
|
* No effective-value computation, no inheritance logic, no mixed-state math:
|
||||||
* the backend is the single source of truth. The provider only:
|
* the backend is the single source of truth. The provider only:
|
||||||
* 1. caches the most recently loaded backend node payload per id, so PATCHes
|
* 1. caches the most recently loaded backend node payload per id so the
|
||||||
* can resolve the implicit DataSource record (creating it lazily when the
|
* drag/settings handlers have direct access to coordinates,
|
||||||
* backend reports `canBeAdded=true`),
|
|
||||||
* 2. emits stable display ordering via `displayOrder`,
|
* 2. emits stable display ordering via `displayOrder`,
|
||||||
* 3. hides flag affordances on synthetic container nodes (synthRoot,
|
* 3. hides flag affordances on synthetic container nodes (synthRoot,
|
||||||
* mandateGroup) by leaving the corresponding TreeNode field undefined.
|
* 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 React from 'react';
|
||||||
|
|
@ -137,6 +146,11 @@ function _isSyntheticContainer(kind: UdbBackendKind): boolean {
|
||||||
return kind === 'synthRoot' || kind === 'mandateGroup';
|
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
|
// Mapping: backend payload -> generic TreeNode
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -165,7 +179,14 @@ function _mapBackendNode(
|
||||||
// Fields expose ONLY neutralize (mapped to parent table's
|
// Fields expose ONLY neutralize (mapped to parent table's
|
||||||
// neutralizeFields list). Scope and RAG are not field-level concepts.
|
// neutralizeFields list). Scope and RAG are not field-level concepts.
|
||||||
node.neutralize = n.effectiveNeutralize;
|
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 {
|
} else {
|
||||||
|
// DataSource family carries the full three-flag set.
|
||||||
node.scope = n.effectiveScope as ScopeValue | 'mixed';
|
node.scope = n.effectiveScope as ScopeValue | 'mixed';
|
||||||
node.neutralize = n.effectiveNeutralize;
|
node.neutralize = n.effectiveNeutralize;
|
||||||
if (n.supportsRag) {
|
if (n.supportsRag) {
|
||||||
|
|
@ -201,11 +222,12 @@ export function createUdbSourcesProvider(
|
||||||
instanceId: string,
|
instanceId: string,
|
||||||
onOpenSettings: (dataSourceId: string, label: string) => void,
|
onOpenSettings: (dataSourceId: string, label: string) => void,
|
||||||
): UdbSourcesProviderHandle {
|
): UdbSourcesProviderHandle {
|
||||||
// Per-id cache of the most recent backend payload. Updated by every
|
// Per-id cache of the most recent backend payload. Used by the
|
||||||
// `loadChildren` call. Read by patch/ensureRecord paths.
|
// 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>();
|
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;
|
if (node.dataSourceId) return node.dataSourceId;
|
||||||
try {
|
try {
|
||||||
if (node.kind === 'connection' || node.kind === 'service'
|
if (node.kind === 'connection' || node.kind === 'service'
|
||||||
|
|
@ -244,13 +266,13 @@ export function createUdbSourcesProvider(
|
||||||
return newId;
|
return newId;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[UdbSourcesProvider] ensureRecord failed', err);
|
console.error('[UdbSourcesProvider] ensureRecordForSettings failed', err);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _onSettingsClick(node: UdbBackendNode): Promise<void> {
|
async function _onSettingsClick(node: UdbBackendNode): Promise<void> {
|
||||||
const dsId = await _ensureRecord(node);
|
const dsId = await _ensureRecordForSettings(node);
|
||||||
if (!dsId) {
|
if (!dsId) {
|
||||||
console.warn('[UdbSourcesProvider] settings click: cannot ensure record', node.key);
|
console.warn('[UdbSourcesProvider] settings click: cannot ensure record', node.key);
|
||||||
return;
|
return;
|
||||||
|
|
@ -258,79 +280,19 @@ export function createUdbSourcesProvider(
|
||||||
onOpenSettings(dsId, node.label);
|
onOpenSettings(dsId, node.label);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** fdsField-specific neutralize: ensure the parent fdsTable record exists,
|
/** Forward a flag mutation to the generic UDB endpoint. The backend
|
||||||
* read its current `neutralizeFields` list, add or remove the field,
|
* resolves the node from `nodeKey`, runs the polymorphic `canEdit`
|
||||||
* PATCH the new list back. Backend treats the FDS-record as the single
|
* permission check, and applies the cascade-reset. */
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function _patchFlag(
|
async function _patchFlag(
|
||||||
ids: string[],
|
ids: string[],
|
||||||
flag: 'scope' | 'neutralize' | 'rag-index',
|
flag: 'neutralize' | 'scope' | 'ragIndexEnabled',
|
||||||
body: Record<string, unknown>,
|
value: unknown,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
for (const id of ids) {
|
for (const nodeKey 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;
|
|
||||||
try {
|
try {
|
||||||
await api.patch(`/api/datasources/${dsId}/${flag}`, body);
|
await api.post(`/api/udb/node/${encodeURIComponent(nodeKey)}/flag/${flag}`, { value });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[UdbSourcesProvider] patch failed', { id, flag, err });
|
console.error('[UdbSourcesProvider] patch failed', { nodeKey, flag, err });
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -340,7 +302,7 @@ export function createUdbSourcesProvider(
|
||||||
rootKey: `udb-sources-${instanceId}`,
|
rootKey: `udb-sources-${instanceId}`,
|
||||||
|
|
||||||
async loadChildren(parentId, _ownership) {
|
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],
|
parents: [parentId],
|
||||||
});
|
});
|
||||||
const nodesByParent = res.data?.nodesByParent || {};
|
const nodesByParent = res.data?.nodesByParent || {};
|
||||||
|
|
@ -352,8 +314,8 @@ export function createUdbSourcesProvider(
|
||||||
|
|
||||||
canPatchScope(node) {
|
canPatchScope(node) {
|
||||||
const data = node.data;
|
const data = node.data;
|
||||||
// Field-level scope makes no sense; it's inherited from the parent table.
|
// Scope only exists on DataSource family; FDS / synthetic containers / fields hide it.
|
||||||
return !!data && !_isSyntheticContainer(data.kind) && data.kind !== 'fdsField';
|
return !!data && !_isSyntheticContainer(data.kind) && !_isFdsKind(data.kind);
|
||||||
},
|
},
|
||||||
|
|
||||||
canPatchNeutralize(node) {
|
canPatchNeutralize(node) {
|
||||||
|
|
@ -363,7 +325,7 @@ export function createUdbSourcesProvider(
|
||||||
|
|
||||||
canPatchRagIndex(node) {
|
canPatchRagIndex(node) {
|
||||||
const data = node.data;
|
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';
|
return !!data && data.supportsRag === true && data.kind !== 'fdsField';
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -371,26 +333,15 @@ export function createUdbSourcesProvider(
|
||||||
// Backend cascades NULL on descendants automatically based on the
|
// Backend cascades NULL on descendants automatically based on the
|
||||||
// existence of explicit child records; the cascadeChildren flag is the
|
// existence of explicit child records; the cascadeChildren flag is the
|
||||||
// FilesTab convention and is irrelevant here.
|
// FilesTab convention and is irrelevant here.
|
||||||
await _patchFlag(ids, 'scope', { scope });
|
await _patchFlag(ids, 'scope', scope);
|
||||||
},
|
},
|
||||||
|
|
||||||
async patchNeutralize(ids, neutralize) {
|
async patchNeutralize(ids, neutralize) {
|
||||||
// fdsField nodes don't have their own DB record — they are addressed
|
await _patchFlag(ids, 'neutralize', neutralize);
|
||||||
// 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);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async patchRagIndex(ids, ragIndexEnabled) {
|
async patchRagIndex(ids, ragIndexEnabled) {
|
||||||
await _patchFlag(ids, 'rag-index', { ragIndexEnabled });
|
await _patchFlag(ids, 'ragIndexEnabled', ragIndexEnabled);
|
||||||
},
|
},
|
||||||
|
|
||||||
customizeDragData(node, dataTransfer) {
|
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() {
|
_diagnosticGetCacheSize() {
|
||||||
return nodeCache.size;
|
return nodeCache.size;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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';
|
const _instanceId = 'inst-42';
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|
@ -68,23 +112,23 @@ beforeEach(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// loadChildren
|
// loadChildren -> POST /api/udb/tree/children (feature-agnostic)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe('UdbSourcesProvider.loadChildren', () => {
|
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__: [] } } });
|
apiMock.post.mockResolvedValue({ data: { nodesByParent: { __root__: [] } } });
|
||||||
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
||||||
|
|
||||||
await provider.loadChildren(null, 'own');
|
await provider.loadChildren(null, 'own');
|
||||||
|
|
||||||
expect(apiMock.post).toHaveBeenCalledWith(
|
expect(apiMock.post).toHaveBeenCalledWith(
|
||||||
`/api/workspace/${_instanceId}/tree/children`,
|
`/api/udb/tree/children`,
|
||||||
{ parents: [null] },
|
{ 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();
|
const conn = _makeBackendNode();
|
||||||
apiMock.post.mockResolvedValue({ data: { nodesByParent: { 'personalRoot': [conn] } } });
|
apiMock.post.mockResolvedValue({ data: { nodesByParent: { 'personalRoot': [conn] } } });
|
||||||
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
||||||
|
|
@ -94,103 +138,42 @@ describe('UdbSourcesProvider.loadChildren', () => {
|
||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
const tn = result[0];
|
const tn = result[0];
|
||||||
expect(tn.id).toBe('conn|c1');
|
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.scope).toBe('personal');
|
||||||
expect(tn.neutralize).toBe(false);
|
expect(tn.neutralize).toBe(false);
|
||||||
expect(tn.ragIndexEnabled).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();
|
const root = _makeSynthRootNode();
|
||||||
apiMock.post.mockResolvedValue({ data: { nodesByParent: { __root__: [root] } } });
|
apiMock.post.mockResolvedValue({ data: { nodesByParent: { __root__: [root] } } });
|
||||||
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
||||||
|
|
||||||
const result = await provider.loadChildren(null, 'own');
|
const [tn] = await provider.loadChildren(null, 'own');
|
||||||
|
expect(tn.scope).toBeUndefined();
|
||||||
expect(result).toHaveLength(1);
|
expect(tn.neutralize).toBeUndefined();
|
||||||
expect(result[0].scope).toBeUndefined();
|
expect(tn.ragIndexEnabled).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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -199,7 +182,7 @@ describe('UdbSourcesProvider.loadChildren', () => {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe('UdbSourcesProvider.canPatch*', () => {
|
describe('UdbSourcesProvider.canPatch*', () => {
|
||||||
it('canPatchScope is false for synthetic containers', async () => {
|
it('canPatch* all false for synthetic containers', async () => {
|
||||||
apiMock.post.mockResolvedValue({
|
apiMock.post.mockResolvedValue({
|
||||||
data: { nodesByParent: { __root__: [_makeSynthRootNode()] } },
|
data: { nodesByParent: { __root__: [_makeSynthRootNode()] } },
|
||||||
});
|
});
|
||||||
|
|
@ -210,175 +193,84 @@ describe('UdbSourcesProvider.canPatch*', () => {
|
||||||
expect(provider.canPatchRagIndex?.(synthNode)).toBe(false);
|
expect(provider.canPatchRagIndex?.(synthNode)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('canPatchRagIndex requires supportsRag=true', async () => {
|
it('canPatchScope is false for any FDS kind', async () => {
|
||||||
apiMock.post.mockResolvedValue({
|
apiMock.post.mockResolvedValue({
|
||||||
data: {
|
data: { nodesByParent: { 'feat|m1|trustee|fi1': [_makeFdsTableNode()] } },
|
||||||
nodesByParent: {
|
|
||||||
personalRoot: [
|
|
||||||
_makeBackendNode({ key: 'a', supportsRag: true }),
|
|
||||||
_makeBackendNode({ key: 'b', supportsRag: false }),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
||||||
const [a, b] = await provider.loadChildren('personalRoot', 'own');
|
const [tbl] = await provider.loadChildren('feat|m1|trustee|fi1', 'own');
|
||||||
expect(provider.canPatchRagIndex?.(a)).toBe(true);
|
expect(provider.canPatchScope?.(tbl)).toBe(false);
|
||||||
expect(provider.canPatchRagIndex?.(b)).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', () => {
|
describe('UdbSourcesProvider.patchScope', () => {
|
||||||
it('PATCHes existing dataSourceId without creating a new record', async () => {
|
it('POSTs to /api/udb/node/{key}/flag/scope with the new value', async () => {
|
||||||
apiMock.post.mockResolvedValueOnce({
|
apiMock.post.mockResolvedValue({ data: {} });
|
||||||
data: {
|
|
||||||
nodesByParent: {
|
|
||||||
personalRoot: [_makeBackendNode({ dataSourceId: 'ds-existing', canBeAdded: false })],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
apiMock.patch.mockResolvedValue({ data: {} });
|
|
||||||
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
||||||
await provider.loadChildren('personalRoot', 'own');
|
|
||||||
|
|
||||||
await provider.patchScope?.(['conn|c1'], 'mandate', true);
|
await provider.patchScope?.(['conn|c1'], 'mandate', true);
|
||||||
|
|
||||||
expect(apiMock.patch).toHaveBeenCalledWith(
|
expect(apiMock.post).toHaveBeenCalledWith(
|
||||||
`/api/datasources/ds-existing/scope`,
|
`/api/udb/node/${encodeURIComponent('conn|c1')}/flag/scope`,
|
||||||
{ scope: 'mandate' },
|
{ 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', () => {
|
describe('UdbSourcesProvider.patchNeutralize', () => {
|
||||||
it('PATCHes /neutralize with the supplied boolean', async () => {
|
it('POSTs to /api/udb/node/{key}/flag/neutralize', async () => {
|
||||||
apiMock.post.mockResolvedValueOnce({
|
apiMock.post.mockResolvedValue({ data: {} });
|
||||||
data: {
|
|
||||||
nodesByParent: {
|
|
||||||
personalRoot: [_makeBackendNode({ dataSourceId: 'ds-1', canBeAdded: false })],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
apiMock.patch.mockResolvedValue({ data: {} });
|
|
||||||
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
||||||
await provider.loadChildren('personalRoot', 'own');
|
|
||||||
|
|
||||||
await provider.patchNeutralize?.(['conn|c1'], true);
|
await provider.patchNeutralize?.(['conn|c1'], true);
|
||||||
|
|
||||||
expect(apiMock.patch).toHaveBeenCalledWith(
|
expect(apiMock.post).toHaveBeenCalledWith(
|
||||||
`/api/datasources/ds-1/neutralize`,
|
`/api/udb/node/${encodeURIComponent('conn|c1')}/flag/neutralize`,
|
||||||
{ neutralize: true },
|
{ 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', () => {
|
describe('UdbSourcesProvider.patchRagIndex', () => {
|
||||||
it('PATCHes /rag-index with the supplied boolean (note dash in URL, camelCase in body)', async () => {
|
it('POSTs to /api/udb/node/{key}/flag/ragIndexEnabled', async () => {
|
||||||
apiMock.post.mockResolvedValueOnce({
|
apiMock.post.mockResolvedValue({ data: {} });
|
||||||
data: {
|
|
||||||
nodesByParent: {
|
|
||||||
personalRoot: [_makeBackendNode({ dataSourceId: 'ds-1', canBeAdded: false })],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
apiMock.patch.mockResolvedValue({ data: {} });
|
|
||||||
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
||||||
await provider.loadChildren('personalRoot', 'own');
|
|
||||||
|
|
||||||
await provider.patchRagIndex?.(['conn|c1'], true);
|
await provider.patchRagIndex?.(['conn|c1'], true);
|
||||||
|
|
||||||
expect(apiMock.patch).toHaveBeenCalledWith(
|
expect(apiMock.post).toHaveBeenCalledWith(
|
||||||
`/api/datasources/ds-1/rag-index`,
|
`/api/udb/node/${encodeURIComponent('conn|c1')}/flag/ragIndexEnabled`,
|
||||||
{ ragIndexEnabled: true },
|
{ value: 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 },
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue