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)
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue