fixes rag and workflow

This commit is contained in:
ValueOn AG 2026-05-19 16:47:52 +02:00
parent 65170d9e4c
commit 1308e6d415
20 changed files with 2272 additions and 2622 deletions

View file

@ -373,11 +373,18 @@ 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<{ sourceId: string; ragIndexEnabled: boolean | null; updated: boolean; cascadedDescendants?: number }> {
): Promise<PatchFlagResponse> {
return await request({
url: `/api/datasources/${dataSourceId}/rag-index`,
method: 'patch',
@ -436,8 +443,42 @@ export interface RagConnectionDto {
} | null;
}
export interface RagFeatureDataSourceDto {
id: string;
label: string;
tableName: string;
featureCode: string;
ragIndexEnabled: boolean;
}
export interface RagFeatureInstanceDto {
featureInstanceId: string;
featureCode: string;
label: string;
mandateId: string;
fileCount: number;
chunkCount: number;
statusCounts: Record<string, number>;
dataSources: RagFeatureDataSourceDto[];
ragEnabled: boolean;
runningJobs?: {
jobId: string;
progress: number;
progressMessage: string;
}[];
lastError?: { jobId: string; errorMessage: string; finishedAt: number | null } | null;
lastSuccess?: {
jobId: string;
finishedAt: number | null;
indexed: number;
skippedDuplicate: number;
failed: number;
} | null;
}
export interface RagInventoryDto {
connections: RagConnectionDto[];
featureInstances?: RagFeatureInstanceDto[];
totals: { files: number; chunks: number; bytes?: number };
}

View file

@ -472,6 +472,29 @@
opacity: 0.35;
}
/* Generic mixed-state indicator (children have differing effective values) */
.flagMixed {
font-weight: 600;
color: var(--color-text-primary, #475569);
opacity: 0.85;
}
/* Generic pending spinner during async action */
.flagSpinner {
display: inline-block;
width: 12px;
height: 12px;
border: 2px solid var(--color-border, #cbd5e1);
border-top-color: var(--color-primary, #2563eb);
border-radius: 50%;
animation: flagSpin 0.7s linear infinite;
vertical-align: middle;
}
@keyframes flagSpin {
to { transform: rotate(360deg); }
}
/* Loading */
.loadingState {
display: flex;

View file

@ -9,6 +9,7 @@ import { usePrompt } from '../../../hooks/usePrompt';
import type {
TreeNode,
TreeNodeProvider,
NodeAction,
FormGeneratorTreeProps,
Ownership,
ScopeValue,
@ -30,10 +31,33 @@ const _SCOPE_EMOJIS: Record<string, string> = {
global: '\uD83C\uDF10',
};
const _NEUTRALIZE_EMOJI = '\uD83D\uDD12';
const _NEUTRALIZE_ON_EMOJI = '\uD83D\uDD12'; // closed padlock
const _NEUTRALIZE_OFF_EMOJI = '\uD83D\uDD13'; // open padlock
const _RAG_ON_EMOJI = '\uD83E\uDDE0'; // brain
const _RAG_OFF_EMOJI = '\uD83E\uDDE0'; // brain (greyed via CSS filter when off)
function _nextScope(current: ScopeValue | undefined): ScopeValue {
const idx = SCOPE_ORDER.indexOf(current ?? 'personal');
/** CSS for the OFF-state of a boolean flag button. We desaturate the colour
* emoji and dim it so the on/off transition is obvious at a glance, even
* when the on/off glyph itself is similar (e.g. brain vs greyed-brain). */
const _OFF_STATE_STYLE: React.CSSProperties = {
filter: 'grayscale(1)',
opacity: 0.45,
};
/** Uniform symbol for any flag whose effective value is 'mixed' across children. */
const _MIXED_SYMBOL = '\u25E9';
/** Internal action keys reserved by the tree for the built-in flag buttons. */
const _ACTION_SCOPE = '__scope__';
const _ACTION_NEUTRALIZE = '__neutralize__';
const _ACTION_RAG = '__rag__';
/** Shared empty set; avoids spurious renders when pendingActions has no entry. */
const _EMPTY_SET: Set<string> = new Set();
function _nextScope(current: ScopeValue | 'mixed' | undefined): ScopeValue {
if (current === 'mixed' || current === undefined) return 'personal';
const idx = SCOPE_ORDER.indexOf(current);
return SCOPE_ORDER[(idx + 1) % SCOPE_ORDER.length];
}
@ -60,6 +84,15 @@ function _buildChildMap<T>(nodes: TreeNode<T>[]): Map<string | '__root__', TreeN
}
for (const [, children] of map) {
children.sort((a, b) => {
const aOrd = a.displayOrder;
const bOrd = b.displayOrder;
if (aOrd !== undefined && bOrd !== undefined) {
if (aOrd !== bOrd) return aOrd - bOrd;
} else if (aOrd !== undefined) {
return -1;
} else if (bOrd !== undefined) {
return 1;
}
if (a.type === 'folder' && b.type !== 'folder') return -1;
if (a.type !== 'folder' && b.type === 'folder') return 1;
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
@ -126,6 +159,8 @@ interface TreeNodeRowProps<T = any> {
isDragging: boolean;
ownership: Ownership;
compact: boolean;
selectable: boolean;
pendingActions: Set<string>;
provider: TreeNodeProvider<T>;
onToggleExpand: (id: string) => void;
onToggleSelect: (id: string, e: React.MouseEvent) => void;
@ -138,6 +173,9 @@ interface TreeNodeRowProps<T = any> {
onSendToChat?: (node: TreeNode<T>) => void;
onCycleScope: (node: TreeNode<T>) => void;
onToggleNeutralize: (node: TreeNode<T>) => void;
onToggleRagIndex: (node: TreeNode<T>) => void;
onCreateChild?: (parentId: string) => void;
onExtraAction: (nodeId: string, action: NodeAction) => void;
onDragStart: (e: React.DragEvent, node: TreeNode<T>) => void;
onDragOver: (e: React.DragEvent, node: TreeNode<T>) => void;
onDragLeave: (e: React.DragEvent) => void;
@ -154,6 +192,8 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
isDragging,
ownership,
compact,
selectable,
pendingActions,
provider,
onToggleExpand,
onToggleSelect,
@ -166,6 +206,9 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
onSendToChat,
onCycleScope,
onToggleNeutralize,
onToggleRagIndex,
onCreateChild,
onExtraAction,
onDragStart,
onDragOver,
onDragLeave,
@ -231,6 +274,12 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
const canDelete = isOwn && provider.canDelete?.(node);
const canPatchScope = isOwn && provider.canPatchScope?.(node);
const canPatchNeutralize = isOwn && provider.canPatchNeutralize?.(node);
const canPatchRagIndex = isOwn && provider.canPatchRagIndex?.(node);
const canCreateChild =
isOwn &&
!!provider.createChild &&
node.type === 'folder' &&
(provider.canCreate ? provider.canCreate(node.id) : true);
const rowClasses = [
styles.nodeRow,
@ -263,17 +312,19 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
>
<div className={styles.indentSpacer} style={{ width: depth * INDENT_PX }} />
<input
type="checkbox"
className={styles.nodeCheckbox}
checked={isSelected}
onChange={() => {}}
onClick={(e) => {
e.stopPropagation();
onToggleSelect(node.id, e as unknown as React.MouseEvent);
}}
tabIndex={-1}
/>
{selectable && (
<input
type="checkbox"
className={styles.nodeCheckbox}
checked={isSelected}
onChange={() => {}}
onClick={(e) => {
e.stopPropagation();
onToggleSelect(node.id, e as unknown as React.MouseEvent);
}}
tabIndex={-1}
/>
)}
{hasChildren ? (
<span
@ -316,6 +367,17 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
</span>
<div className={styles.nodeActionsHover}>
{canCreateChild && onCreateChild && (
<button
className={styles.emojiBtn}
onClick={(e) => { e.stopPropagation(); onCreateChild(node.id); }}
title="Neuer Unterordner"
tabIndex={-1}
>
{'\u2795'}
</button>
)}
{canRename && (
<button
className={styles.emojiBtn}
@ -327,7 +389,7 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
</button>
)}
{node.type !== 'folder' && (
{node.type !== 'folder' && provider.downloadNode && (
<button
className={styles.emojiBtn}
onClick={(e) => { e.stopPropagation(); onDownload(node); }}
@ -352,6 +414,49 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
</div>
<div className={styles.nodeActionsPersistent}>
{/* Order (left-to-right): extraActions (e.g. settings) -> RAG -> sendToChat -> scope -> neutralize. */}
{node.extraActions?.map((action) => (
<button
key={action.key}
className={`${styles.emojiBtn} ${action.disabled ? styles.emojiBtnReadonly : ''}`}
onClick={(e) => {
e.stopPropagation();
if (!action.disabled) onExtraAction(node.id, action);
}}
title={action.tooltip}
tabIndex={-1}
disabled={action.disabled}
>
{pendingActions.has(action.key)
? <span className={styles.flagSpinner} />
: action.value === 'mixed'
? <span className={styles.flagMixed}>{_MIXED_SYMBOL}</span>
: action.icon}
</button>
))}
{node.ragIndexEnabled !== undefined && (
<button
className={`${styles.emojiBtn} ${canPatchRagIndex ? '' : styles.emojiBtnReadonly}`}
onClick={(e) => {
e.stopPropagation();
if (canPatchRagIndex) onToggleRagIndex(node);
}}
title={node.ragIndexEnabled === 'mixed'
? 'Gemischt - Klick setzt explizit'
: node.ragIndexEnabled ? 'RAG-Indexierung an' : 'RAG-Indexierung aus'}
tabIndex={-1}
>
{pendingActions.has(_ACTION_RAG)
? <span className={styles.flagSpinner} />
: node.ragIndexEnabled === 'mixed'
? <span className={styles.flagMixed}>{_MIXED_SYMBOL}</span>
: node.ragIndexEnabled === true
? _RAG_ON_EMOJI
: <span style={_OFF_STATE_STYLE}>{_RAG_OFF_EMOJI}</span>}
</button>
)}
{onSendToChat && (
<button
className={styles.emojiBtn}
@ -373,10 +478,14 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
e.stopPropagation();
if (canPatchScope) onCycleScope(node);
}}
title={`Scope: ${node.scope}`}
title={node.scope === 'mixed' ? 'Gemischt - Klick setzt explizit' : `Scope: ${node.scope}`}
tabIndex={-1}
>
{_SCOPE_EMOJIS[node.scope] ?? _SCOPE_EMOJIS.personal}
{pendingActions.has(_ACTION_SCOPE)
? <span className={styles.flagSpinner} />
: node.scope === 'mixed'
? <span className={styles.flagMixed}>{_MIXED_SYMBOL}</span>
: (_SCOPE_EMOJIS[node.scope] ?? _SCOPE_EMOJIS.personal)}
</button>
)}
@ -387,11 +496,18 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
e.stopPropagation();
if (canPatchNeutralize) onToggleNeutralize(node);
}}
title={node.neutralize ? 'Neutralisiert' : 'Nicht neutralisiert'}
title={node.neutralize === 'mixed'
? 'Gemischt - Klick setzt explizit'
: node.neutralize ? 'Neutralisiert' : 'Nicht neutralisiert'}
tabIndex={-1}
style={{ opacity: node.neutralize ? 1 : 0.35 }}
>
{_NEUTRALIZE_EMOJI}
{pendingActions.has(_ACTION_NEUTRALIZE)
? <span className={styles.flagSpinner} />
: node.neutralize === 'mixed'
? <span className={styles.flagMixed}>{_MIXED_SYMBOL}</span>
: node.neutralize === true
? _NEUTRALIZE_ON_EMOJI
: <span style={_OFF_STATE_STYLE}>{_NEUTRALIZE_OFF_EMOJI}</span>}
</button>
)}
</div>
@ -413,6 +529,8 @@ export function FormGeneratorTree<T = any>({
onRefresh,
onSendToChat,
allowCreateFolder = true,
selectable = true,
refreshAfterAction = false,
className,
}: FormGeneratorTreeProps<T>) {
const { t } = useLanguage();
@ -428,11 +546,103 @@ export function FormGeneratorTree<T = any>({
const [dragOverId, setDragOverId] = useState<string | null>(null);
const [draggingIds, setDraggingIds] = useState<Set<string>>(new Set());
const [filterText, setFilterText] = useState('');
/** Map of nodeId -> set of action keys currently pending (for spinner rendering). */
const [pendingActions, setPendingActions] = useState<Map<string, Set<string>>>(new Map());
const lastSelectedIdRef = useRef<string | null>(null);
const treeContentRef = useRef<HTMLDivElement>(null);
/** Tracks node ids for which auto-expand has already fired (one-shot). */
const autoExpandedRef = useRef<Set<string>>(new Set());
/** Stable ref to the current flatEntries so _refreshVisibleAttributes can
* read visible IDs without being in the dependency array. */
const flatEntriesRef = useRef<FlatEntry<T>[]>([]);
/** Deduplicating node append: merges `incoming` into `prev` by id. */
const _mergeNodes = useCallback(
(prev: TreeNode<T>[], incoming: TreeNode<T>[]): TreeNode<T>[] => {
if (incoming.length === 0) return prev;
const existingIds = new Set(prev.map((n) => n.id));
const unique = incoming.filter((n) => !existingIds.has(n.id));
if (unique.length === 0) return prev;
return [...prev, ...unique];
},
[],
);
/** 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. */
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()];
});
}
}, [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. */
const _runAction = useCallback(
async (nodeId: string, actionKey: string, fn: () => Promise<void> | void) => {
setPendingActions((prev) => {
const next = new Map(prev);
const current = new Set(next.get(nodeId) ?? []);
current.add(actionKey);
next.set(nodeId, current);
return next;
});
try {
await fn();
if (refreshAfterAction || provider.refreshAttributes) {
await _refreshVisibleAttributes();
}
} finally {
setPendingActions((prev) => {
const next = new Map(prev);
const current = new Set(next.get(nodeId) ?? []);
current.delete(actionKey);
if (current.size === 0) next.delete(nodeId);
else next.set(nodeId, current);
return next;
});
}
},
[refreshAfterAction, _refreshVisibleAttributes],
);
const _loadRoot = useCallback(async () => {
setLoading(true);
autoExpandedRef.current.clear();
setExpandedIds(new Set());
try {
const rootNodes = await provider.loadChildren(null, ownership);
setNodes(rootNodes);
@ -448,6 +658,45 @@ export function FormGeneratorTree<T = any>({
_loadRoot();
}, [_loadRoot]);
/** Auto-expand nodes with `defaultExpanded=true` from backend, one-shot per id.
* Fetches children first, then sets expandedIds + merges atomically so the
* expanded arrow never appears without visible children. */
useEffect(() => {
const targets = nodes.filter(
(n) => n.defaultExpanded === true && !autoExpandedRef.current.has(n.id),
);
if (targets.length === 0) return;
const targetIds = targets.map((t) => t.id);
for (const id of targetIds) autoExpandedRef.current.add(id);
let cancelled = false;
(async () => {
const childMap = _buildChildMap(nodes);
const toFetch = targetIds.filter((id) => {
const existing = childMap.get(id);
return !existing || existing.length === 0;
});
if (toFetch.length > 0) {
const results = await Promise.all(
toFetch.map((id) =>
provider.loadChildren(id, ownership).catch(() => [] as TreeNode<T>[]),
),
);
if (cancelled) return;
const flat = results.flat();
if (flat.length > 0) {
setNodes((prev) => _mergeNodes(prev, flat));
}
}
if (cancelled) return;
setExpandedIds((prev) => {
const next = new Set(prev);
for (const id of targetIds) next.add(id);
return next;
});
})();
return () => { cancelled = true; };
}, [nodes, provider, ownership, _mergeNodes]);
const flatEntriesRaw = useMemo(() => _flatten(nodes, expandedIds), [nodes, expandedIds]);
const flatEntries = useMemo(() => {
@ -468,6 +717,8 @@ export function FormGeneratorTree<T = any>({
return flatEntriesRaw.filter((e) => matchIds.has(e.node.id));
}, [flatEntriesRaw, filterText, nodes]);
flatEntriesRef.current = flatEntries;
const _updateSelection = useCallback(
(newSelection: Set<string>) => {
setSelectedIds(newSelection);
@ -479,32 +730,39 @@ export function FormGeneratorTree<T = any>({
const _handleToggleExpand = useCallback(
async (id: string) => {
const wasExpanded = expandedIds.has(id);
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
const node = nodes.find((n) => n.id === id);
if (node && !wasExpanded) {
const childMap = _buildChildMap(nodes);
const existingChildren = childMap.get(id);
if (!existingChildren || existingChildren.length === 0) {
const childNodes = await provider.loadChildren(id, ownership);
if (childNodes.length > 0) {
setNodes((prev) => [...prev, ...childNodes]);
if (wasExpanded) {
// Collapse: remove all descendants from nodes state and expandedIds.
const descendantIds = new Set<string>();
const _collectDescendants = (parentId: string) => {
for (const n of nodes) {
if (n.parentId === parentId && !descendantIds.has(n.id)) {
descendantIds.add(n.id);
_collectDescendants(n.id);
}
}
};
_collectDescendants(id);
setExpandedIds((prev) => {
const next = new Set(prev);
next.delete(id);
for (const did of descendantIds) next.delete(did);
return next;
});
setNodes((prev) => prev.filter((n) => !descendantIds.has(n.id)));
} else {
// Expand: load children from backend (always fresh).
setExpandedIds((prev) => new Set([...prev, id]));
const childNodes = await provider.loadChildren(id, ownership);
if (childNodes.length > 0) {
setNodes((prev) => _mergeNodes(prev, childNodes));
}
setTimeout(() => {
_scrollExpandedNodeToCenter(id);
}, 50);
}
},
[nodes, expandedIds, provider, ownership],
[nodes, expandedIds, provider, ownership, _mergeNodes],
);
const _scrollExpandedNodeToCenter = useCallback((nodeId: string) => {
@ -523,6 +781,10 @@ export function FormGeneratorTree<T = any>({
const _handleToggleSelect = useCallback(
(id: string, e: React.MouseEvent) => {
if (!selectable) {
setFocusedId(id);
return;
}
const newSelection = new Set(selectedIds);
if (e.shiftKey && lastSelectedIdRef.current) {
@ -566,7 +828,7 @@ export function FormGeneratorTree<T = any>({
lastSelectedIdRef.current = id;
_updateSelection(newSelection);
},
[selectedIds, flatEntries, nodes, ownership, _updateSelection],
[selectable, selectedIds, flatEntries, nodes, ownership, _updateSelection],
);
const _handleNodeClick = useCallback(
@ -603,18 +865,23 @@ export function FormGeneratorTree<T = any>({
onRefresh?.();
}, [_loadRoot, _updateSelection, onRefresh]);
const _handleNewFolder = useCallback(async () => {
/** Create a new folder under `parentId`. `null` = legacy top-level (the
* provider may map this to its own visible root, e.g. a synth-root). */
const _createFolderAt = useCallback(async (parentId: string | null) => {
if (ownership !== 'own' || !provider.createChild || !allowCreateFolder) return;
const parentId = _resolveNewFolderParentId(selectedIds, nodes);
if (provider.canCreate && !provider.canCreate(parentId)) return;
const name = await prompt('Neuer Ordnername:', { title: 'Neuer Ordner', placeholder: 'Ordnername' });
const trimmed = name?.trim();
if (!trimmed) return;
try {
const newNode = await provider.createChild(parentId, trimmed);
setNodes((prev) => [...prev, newNode]);
if (parentId) {
setExpandedIds((prev) => new Set(prev).add(parentId));
setNodes((prev) => _mergeNodes(prev, [newNode]));
// The provider may have re-parented `newNode` (e.g. onto a synth-root)
// when `parentId === null`; expand whichever parent the resulting node
// actually points at, so the new folder is visible.
const visibleParent = newNode.parentId ?? null;
if (visibleParent) {
setExpandedIds((prev) => new Set(prev).add(visibleParent));
}
} catch {
await _handleRefresh();
@ -623,12 +890,15 @@ export function FormGeneratorTree<T = any>({
ownership,
provider,
allowCreateFolder,
selectedIds,
nodes,
prompt,
_handleRefresh,
]);
const _handleNewFolder = useCallback(async () => {
const parentId = _resolveNewFolderParentId(selectedIds, nodes);
await _createFolderAt(parentId);
}, [_createFolderAt, selectedIds, nodes]);
const _handleDelete = useCallback(
async (id: string) => {
const node = nodes.find((n) => n.id === id);
@ -659,29 +929,43 @@ export function FormGeneratorTree<T = any>({
async (node: TreeNode<T>) => {
const newScope = _nextScope(node.scope);
const isFolder = node.type === 'folder';
await provider.patchScope?.([node.id], newScope, isFolder);
setNodes((prev) => {
if (!isFolder) return prev.map((n) => (n.id === node.id ? { ...n, scope: newScope } : n));
const descendantIds = new Set(_collectDescendantIds(node.id, prev));
descendantIds.add(node.id);
return prev.map((n) => descendantIds.has(n.id) ? { ...n, scope: newScope } : n);
await _runAction(node.id, _ACTION_SCOPE, async () => {
await provider.patchScope?.([node.id], newScope, isFolder);
});
},
[provider],
[provider, _runAction],
);
const _handleToggleNeutralize = useCallback(
async (node: TreeNode<T>) => {
const newValue = !node.neutralize;
await provider.patchNeutralize?.([node.id], newValue);
setNodes((prev) => {
if (node.type !== 'folder') return prev.map((n) => (n.id === node.id ? { ...n, neutralize: newValue } : n));
const descendantIds = new Set(_collectDescendantIds(node.id, prev));
descendantIds.add(node.id);
return prev.map((n) => descendantIds.has(n.id) ? { ...n, neutralize: newValue } : n);
const newValue = node.neutralize === 'mixed' ? false : !node.neutralize;
await _runAction(node.id, _ACTION_NEUTRALIZE, async () => {
await provider.patchNeutralize?.([node.id], newValue);
});
},
[provider],
[provider, _runAction],
);
const _handleToggleRagIndex = useCallback(
async (node: TreeNode<T>) => {
const newValue = node.ragIndexEnabled === 'mixed' ? false : !node.ragIndexEnabled;
await _runAction(node.id, _ACTION_RAG, async () => {
await provider.patchRagIndex?.([node.id], newValue);
});
},
[provider, _runAction],
);
/** Generic dispatcher for provider-defined extraActions. Tree knows nothing
* about the action semantics; it only manages the pending spinner. */
const _handleExtraAction = useCallback(
async (nodeId: string, action: NodeAction) => {
if (!action.onClick) return;
await _runAction(nodeId, action.key, async () => {
await action.onClick!();
});
},
[_runAction],
);
const _handleDragStart = useCallback(
@ -700,10 +984,14 @@ export function FormGeneratorTree<T = any>({
e.dataTransfer.setData('application/tree-items', JSON.stringify(chatPayload));
e.dataTransfer.setData('text/plain', chatPayload.map((p) => p.name).join(', '));
if (provider.customizeDragData) {
provider.customizeDragData(node, e.dataTransfer);
}
e.dataTransfer.effectAllowed = 'copyMove';
setDraggingIds(new Set(dragIds));
},
[selectedIds, nodes, provider.rootKey],
[selectedIds, nodes, provider],
);
const _handleDragOver = useCallback(
@ -948,7 +1236,7 @@ export function FormGeneratorTree<T = any>({
</div>
)}
{selectedIds.size > 0 && batchActions.length > 0 && (
{selectable && selectedIds.size > 0 && batchActions.length > 0 && (
<div className={styles.batchToolbar}>
<span className={styles.batchCount}>{selectedIds.size} selected</span>
{batchActions.map((action: TreeBatchAction) => {
@ -1009,6 +1297,8 @@ export function FormGeneratorTree<T = any>({
isDragging={draggingIds.has(entry.node.id)}
ownership={ownership}
compact={compact}
selectable={selectable}
pendingActions={pendingActions.get(entry.node.id) ?? _EMPTY_SET}
provider={provider}
onToggleExpand={_handleToggleExpand}
onToggleSelect={_handleToggleSelect}
@ -1021,6 +1311,9 @@ export function FormGeneratorTree<T = any>({
onSendToChat={onSendToChat}
onCycleScope={_handleCycleScope}
onToggleNeutralize={_handleToggleNeutralize}
onToggleRagIndex={_handleToggleRagIndex}
onCreateChild={allowCreateFolder ? _createFolderAt : undefined}
onExtraAction={_handleExtraAction}
onDragStart={_handleDragStart}
onDragOver={_handleDragOver}
onDragLeave={_handleDragLeave}

View file

@ -1,12 +1,18 @@
// Copyright (c) 2026 Patrick Motsch
// All rights reserved.
import { describe, expect, it, vi, beforeEach } from 'vitest';
import React from 'react';
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { render, screen, waitFor, within, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { FormGeneratorTree } from '../FormGeneratorTree';
import type { TreeNode, TreeNodeProvider, TreeBatchAction } from '../types';
// Silence unused-import warnings for React (needed only for the JSX/UMD type
// resolution under tsconfig.app fallback paths).
void React;
const { mockPrompt } = vi.hoisted(() => ({
mockPrompt: vi.fn(() => Promise.resolve('NeuOrdner')),
}));
@ -17,6 +23,27 @@ vi.mock('../../../../hooks/usePrompt', () => ({
PromptDialog: () => null,
}),
}));
vi.mock('../../../../providers/language/LanguageContext', () => ({
useLanguage: () => ({
t: (key: string, vars?: Record<string, string>) => {
if (!vars) return key;
let out = key;
for (const [k, v] of Object.entries(vars)) out = out.replace(`{${k}}`, String(v));
return out;
},
availableLanguages: ['de'],
language: 'de',
setLanguage: () => {},
}),
}));
vi.mock('../../../../hooks/useConfirm', () => ({
useConfirm: () => ({
confirm: () => Promise.resolve(true),
ConfirmDialog: () => null,
}),
}));
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
@ -68,7 +95,7 @@ const _orphanFile: TreeNode = {
function _createMockProvider(nodes: TreeNode[]): TreeNodeProvider {
return {
rootKey: 'test',
loadChildren: vi.fn(async (parentId) =>
loadChildren: vi.fn(async (parentId: string | null): Promise<TreeNode[]> =>
nodes.filter((n) => n.parentId === parentId),
),
canCreate: vi.fn(() => true),
@ -77,6 +104,7 @@ function _createMockProvider(nodes: TreeNode[]): TreeNodeProvider {
canMove: vi.fn(() => true),
canPatchScope: vi.fn((node) => node.ownership === 'own'),
canPatchNeutralize: vi.fn((node) => node.ownership === 'own'),
canPatchRagIndex: vi.fn((node) => node.ownership === 'own'),
createChild: vi.fn(async (parentId, name) => ({
id: 'new-1',
name,
@ -90,6 +118,7 @@ function _createMockProvider(nodes: TreeNode[]): TreeNodeProvider {
moveNodes: vi.fn(async () => {}),
patchScope: vi.fn(async () => {}),
patchNeutralize: vi.fn(async () => {}),
patchRagIndex: vi.fn(async () => {}),
getBatchActions: vi.fn(() => []),
};
}
@ -119,7 +148,7 @@ describe('FormGeneratorTree', () => {
it('shows loading spinner while loading', () => {
const provider = _createMockProvider([]);
provider.loadChildren = vi.fn(() => new Promise(() => {})); // never resolves
provider.loadChildren = vi.fn(() => new Promise<TreeNode[]>(() => {})); // never resolves
render(<FormGeneratorTree provider={provider} ownership="own" />);
const tree = screen.getByRole('tree');
@ -607,6 +636,7 @@ describe('FormGeneratorTree', () => {
expect(provider.patchScope).toHaveBeenCalledWith(
['f1'],
'featureInstance',
true,
);
});
});
@ -780,4 +810,396 @@ describe('FormGeneratorTree', () => {
expect(screen.queryByText('Delete All')).not.toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
// Mixed-state rendering (generic, used by UDB Sources)
// ---------------------------------------------------------------------------
describe('Mixed-state rendering', () => {
const _mixedFolder: TreeNode = {
id: 'mx1',
name: 'Mixed Folder',
type: 'folder',
parentId: null,
ownership: 'own',
scope: 'mixed',
neutralize: 'mixed',
};
it('renders mixed symbol for scope and neutralize when value is "mixed"', async () => {
const provider = _createMockProvider([_mixedFolder]);
render(<FormGeneratorTree provider={provider} ownership="own" />);
await waitFor(() => {
expect(screen.getByText('Mixed Folder')).toBeInTheDocument();
});
// Both scope and neutralize buttons share the same mixed tooltip
const mixedBtns = screen.getAllByTitle('Gemischt - Klick setzt explizit');
expect(mixedBtns).toHaveLength(2);
});
it('clicking mixed scope cycles deterministically to "personal"', async () => {
const user = userEvent.setup();
const provider = _createMockProvider([_mixedFolder]);
render(<FormGeneratorTree provider={provider} ownership="own" />);
await waitFor(() => {
expect(screen.getByText('Mixed Folder')).toBeInTheDocument();
});
const scopeBtn = screen.getAllByTitle('Gemischt - Klick setzt explizit')[0];
await user.click(scopeBtn);
await waitFor(() => {
expect(provider.patchScope).toHaveBeenCalledWith(['mx1'], 'personal', true);
});
});
});
// ---------------------------------------------------------------------------
// Generic extraActions slot (used by UDB Sources for RAG toggle + settings)
// ---------------------------------------------------------------------------
describe('extraActions', () => {
it('renders extraActions buttons and calls onClick', async () => {
const user = userEvent.setup();
const onClick = vi.fn();
const _actionNode: TreeNode = {
id: 'a1',
name: 'Action Node',
type: 'item',
parentId: null,
ownership: 'own',
extraActions: [
{ key: 'foo', icon: 'F', tooltip: 'Foo Action', onClick },
],
};
const provider = _createMockProvider([_actionNode]);
render(<FormGeneratorTree provider={provider} ownership="own" />);
await waitFor(() => {
expect(screen.getByText('Action Node')).toBeInTheDocument();
});
await user.click(screen.getByTitle('Foo Action'));
await waitFor(() => {
expect(onClick).toHaveBeenCalled();
});
});
it('renders mixed symbol for extraAction with value="mixed"', async () => {
const _mixedActionNode: TreeNode = {
id: 'a2',
name: 'Mixed Action Node',
type: 'item',
parentId: null,
ownership: 'own',
extraActions: [
{ key: 'rag', icon: 'R', tooltip: 'RAG', value: 'mixed' },
],
};
const provider = _createMockProvider([_mixedActionNode]);
render(<FormGeneratorTree provider={provider} ownership="own" />);
await waitFor(() => {
expect(screen.getByText('Mixed Action Node')).toBeInTheDocument();
});
const btn = screen.getByTitle('RAG');
expect(btn.textContent).not.toBe('R'); // icon replaced by mixed symbol
});
});
// ---------------------------------------------------------------------------
// RAG-Index Toggle (third built-in flag)
// ---------------------------------------------------------------------------
describe('RAG-Index toggle', () => {
const _ownFolderRag: TreeNode = {
..._ownFolder,
ragIndexEnabled: false,
};
it('renders RAG button when ragIndexEnabled is defined', async () => {
const provider = _createMockProvider([_ownFolderRag]);
render(<FormGeneratorTree provider={provider} ownership="own" />);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
expect(screen.getByTitle('RAG-Indexierung aus')).toBeInTheDocument();
});
it('clicking RAG button calls provider.patchRagIndex with toggled value', async () => {
const user = userEvent.setup();
const provider = _createMockProvider([_ownFolderRag]);
render(<FormGeneratorTree provider={provider} ownership="own" />);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
await user.click(screen.getByTitle('RAG-Indexierung aus'));
await waitFor(() => {
expect(provider.patchRagIndex).toHaveBeenCalledWith(['f1'], true);
});
});
it('hides RAG button when ragIndexEnabled is undefined (synthetic containers)', async () => {
const provider = _createMockProvider([_ownFolder]);
render(<FormGeneratorTree provider={provider} ownership="own" />);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
expect(screen.queryByTitle('RAG-Indexierung aus')).not.toBeInTheDocument();
expect(screen.queryByTitle('RAG-Indexierung an')).not.toBeInTheDocument();
});
it('renders mixed symbol when ragIndexEnabled is "mixed"', async () => {
const _mixedRag: TreeNode = { ..._ownFolderRag, ragIndexEnabled: 'mixed' };
const provider = _createMockProvider([_mixedRag]);
render(<FormGeneratorTree provider={provider} ownership="own" />);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
const mixedBtns = screen.getAllByTitle('Gemischt - Klick setzt explizit');
expect(mixedBtns.length).toBeGreaterThanOrEqual(1);
});
it('mixed RAG cycles deterministically to false on click', async () => {
const user = userEvent.setup();
const _mixedRag: TreeNode = {
..._ownFolder,
scope: 'personal',
neutralize: false,
ragIndexEnabled: 'mixed',
};
const provider = _createMockProvider([_mixedRag]);
render(<FormGeneratorTree provider={provider} ownership="own" />);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
const ragBtn = screen.getByTitle('Gemischt - Klick setzt explizit');
await user.click(ragBtn);
await waitFor(() => {
expect(provider.patchRagIndex).toHaveBeenCalledWith(['f1'], false);
});
});
});
// ---------------------------------------------------------------------------
// displayOrder (provider-controlled sorting)
// ---------------------------------------------------------------------------
describe('displayOrder', () => {
it('siblings with displayOrder render in numeric ascending order', async () => {
const _b: TreeNode = {
id: 'b', name: 'Mandanten-Daten', type: 'folder',
parentId: null, ownership: 'own', displayOrder: 1,
};
const _a: TreeNode = {
id: 'a', name: 'Persoenliche Quellen', type: 'folder',
parentId: null, ownership: 'own', displayOrder: 0,
};
const provider = _createMockProvider([_b, _a]);
render(<FormGeneratorTree provider={provider} ownership="own" />);
await waitFor(() => {
expect(screen.getByText('Persoenliche Quellen')).toBeInTheDocument();
});
const items = screen.getAllByRole('treeitem');
expect(items[0]).toHaveTextContent('Persoenliche Quellen');
expect(items[1]).toHaveTextContent('Mandanten-Daten');
});
it('node with displayOrder renders before sibling without (regardless of name)', async () => {
const _withOrder: TreeNode = {
id: 'wo', name: 'Zzz', type: 'folder',
parentId: null, ownership: 'own', displayOrder: 0,
};
const _withoutOrder: TreeNode = {
id: 'no', name: 'Aaa', type: 'folder',
parentId: null, ownership: 'own',
};
const provider = _createMockProvider([_withoutOrder, _withOrder]);
render(<FormGeneratorTree provider={provider} ownership="own" />);
await waitFor(() => {
expect(screen.getByText('Zzz')).toBeInTheDocument();
});
const items = screen.getAllByRole('treeitem');
expect(items[0]).toHaveTextContent('Zzz');
expect(items[1]).toHaveTextContent('Aaa');
});
it('siblings without displayOrder fall back to folder-first / alphabetic', async () => {
const _file: TreeNode = {
id: 'fi', name: 'aaa.txt', type: 'file',
parentId: null, ownership: 'own',
};
const _folder: TreeNode = {
id: 'fo', name: 'zzz', type: 'folder',
parentId: null, ownership: 'own',
};
const provider = _createMockProvider([_file, _folder]);
render(<FormGeneratorTree provider={provider} ownership="own" />);
await waitFor(() => {
expect(screen.getByText('aaa.txt')).toBeInTheDocument();
});
const items = screen.getAllByRole('treeitem');
expect(items[0]).toHaveTextContent('zzz');
expect(items[1]).toHaveTextContent('aaa.txt');
});
});
// ---------------------------------------------------------------------------
// defaultExpanded (auto-expand hint from provider)
// ---------------------------------------------------------------------------
describe('defaultExpanded', () => {
it('auto-expands a node carrying defaultExpanded=true and loads its children', async () => {
const _root: TreeNode = {
id: 'root', name: 'Root', type: 'folder',
parentId: null, ownership: 'own', defaultExpanded: true,
};
const _child: TreeNode = {
id: 'child', name: 'Child', type: 'folder',
parentId: 'root', ownership: 'own',
};
const provider = _createMockProvider([_root, _child]);
render(<FormGeneratorTree provider={provider} ownership="own" />);
await waitFor(() => {
expect(screen.getByText('Root')).toBeInTheDocument();
});
// Without auto-expand the child would be hidden until clicking the chevron.
await waitFor(() => {
expect(screen.getByText('Child')).toBeInTheDocument();
});
expect(provider.loadChildren).toHaveBeenCalledWith('root', 'own');
});
it('does not auto-expand a node without defaultExpanded', async () => {
const _root: TreeNode = {
id: 'root', name: 'Root', type: 'folder',
parentId: null, ownership: 'own',
};
const _child: TreeNode = {
id: 'child', name: 'Child', type: 'folder',
parentId: 'root', ownership: 'own',
};
const provider = _createMockProvider([_root, _child]);
render(<FormGeneratorTree provider={provider} ownership="own" />);
await waitFor(() => expect(screen.getByText('Root')).toBeInTheDocument());
// Child must NOT appear without manual expand.
expect(screen.queryByText('Child')).not.toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
// refreshAfterAction (backend-authoritative mode)
// ---------------------------------------------------------------------------
describe('refreshAfterAction', () => {
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
/>,
);
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();
});
// After action, at least one extra loadChildren(null, 'own') happened.
const newCalls = (provider.loadChildren as ReturnType<typeof vi.fn>).mock.calls;
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);
});
});
// ---------------------------------------------------------------------------
// selectable=false (UDB Sources mode)
// ---------------------------------------------------------------------------
describe('selectable=false', () => {
it('hides checkboxes when selectable=false', async () => {
const provider = _createMockProvider([_ownFolder]);
const { container } = render(
<FormGeneratorTree provider={provider} ownership="own" selectable={false} />,
);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
expect(container.querySelector('input[type="checkbox"]')).not.toBeInTheDocument();
});
it('hides batch-action toolbar when selectable=false', async () => {
const user = userEvent.setup();
const action: TreeBatchAction = {
key: 'del',
label: 'Delete',
onClick: vi.fn(),
};
const provider = _createMockProvider([_ownFolder]);
provider.getBatchActions = vi.fn(() => [action]);
render(
<FormGeneratorTree provider={provider} ownership="own" selectable={false} />,
);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
await user.click(screen.getByRole('treeitem', { name: /My Folder/i }));
expect(screen.queryByText('Delete')).not.toBeInTheDocument();
});
});
});

View file

@ -12,6 +12,27 @@ vi.mock('../../../../hooks/usePrompt', () => ({
PromptDialog: () => null,
}),
}));
vi.mock('../../../../providers/language/LanguageContext', () => ({
useLanguage: () => ({
t: (key: string, vars?: Record<string, string>) => {
if (!vars) return key;
let out = key;
for (const [k, v] of Object.entries(vars)) out = out.replace(`{${k}}`, String(v));
return out;
},
availableLanguages: ['de'],
language: 'de',
setLanguage: () => {},
}),
}));
vi.mock('../../../../hooks/useConfirm', () => ({
useConfirm: () => ({
confirm: () => Promise.resolve(true),
ConfirmDialog: () => null,
}),
}));
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------

View file

@ -8,7 +8,7 @@ interface FolderData {
name: string;
parentId?: string | null;
scope?: ScopeValue;
neutralize?: boolean;
neutralize?: boolean | 'mixed';
contextOrphan?: boolean;
}
@ -52,6 +52,33 @@ function _mapFileToNode(file: FileData, ownership: Ownership): TreeNode {
};
}
/** Stable synthetic root id per ownership scope. The real top-level
* folders/files attach their `parentId` to this id once we re-parent them
* in `loadChildren`. The id stays inside the FE provider; the backend
* never sees it. */
const _SYNTH_ROOT_ID = (ownership: Ownership): string => `__filesRoot:${ownership}`;
/** Build the synthetic root node. Its only job is to:
* - act as a drop-target for moving items back to top-level,
* - expose a global neutralize/scope toggle that cascades to every
* top-level descendant.
* Its scope/neutralize values are intentionally `undefined` (= "no own
* state") the icons render an indeterminate state and a click sets the
* intent on every owned descendant. */
function _makeSyntheticRoot(ownership: Ownership): TreeNode {
return {
id: _SYNTH_ROOT_ID(ownership),
name: '/',
type: 'folder',
parentId: null,
ownership,
icon: <FaFolder style={{ color: '#666' }} />,
defaultExpanded: true,
scope: 'personal',
neutralize: false,
};
}
export function createFolderFileProvider(): TreeNodeProvider {
const ownerParam = (ownership: Ownership) => (ownership === 'own' ? 'me' : 'shared');
const typeMap = new Map<string, 'folder' | 'file'>();
@ -66,22 +93,74 @@ export function createFolderFileProvider(): TreeNodeProvider {
return typeMap.get(id) === 'file';
}
/** When a batch contains the synthetic root id, expand it into the set of
* every owned top-level folder + file id. The backend doesn't know the
* synthetic root, so we must materialize it client-side. Folder patches
* are sent with `cascadeChildren=true` (handled by patchScope) so the
* whole subtree is covered without enumerating every descendant here. */
async function _expandSyntheticRoots(ids: string[]): Promise<string[]> {
const synthIds = ids.filter((id) => id.startsWith('__filesRoot:'));
if (synthIds.length === 0) return ids;
const out = new Set<string>(ids.filter((id) => !id.startsWith('__filesRoot:')));
for (const synthId of synthIds) {
const ownership: Ownership = synthId.endsWith(':shared') ? 'shared' : 'own';
const owner = ownership === 'own' ? 'me' : 'shared';
try {
const foldersRes = await api.get('/api/files/folders/tree', { params: { owner } });
const allFolders: FolderData[] = foldersRes.data ?? [];
for (const f of allFolders) {
if ((f.parentId ?? null) === null) out.add(f.id);
}
const paginationParam = JSON.stringify({ filters: { folderId: null }, pageSize: 500 });
const filesRes = await api.get('/api/files/list', { params: { pagination: paginationParam } });
const data = filesRes.data;
const rawFiles: FileData[] = (data && typeof data === 'object' && 'items' in data)
? (Array.isArray(data.items) ? data.items : [])
: (Array.isArray(data) ? data : []);
for (const f of rawFiles) {
if ((f.folderId ?? null) === null) out.add(f.id);
}
} catch (err) {
console.warn('[FolderFileProvider] synthetic-root expansion failed', err);
}
}
return Array.from(out);
}
return {
rootKey: 'files',
async loadChildren(parentId, ownership) {
// Synthetic root: when the tree asks for top-level (parentId=null),
// we return ONE container ("/") instead of the real items. The real
// top-level items are then loaded as children of that container the
// next time the tree resolves it (auto-expanded via defaultExpanded).
if (parentId === null) {
return [_makeSyntheticRoot(ownership)];
}
const synthRootId = _SYNTH_ROOT_ID(ownership);
// Backend uses `null` for top-level parents; the FE layer remaps the
// synthetic root id back to null before talking to the API.
const apiParentId = parentId === synthRootId ? null : parentId;
const owner = ownerParam(ownership);
const nodes: TreeNode[] = [];
const foldersRes = await api.get('/api/files/folders/tree', { params: { owner } });
const allFolders: FolderData[] = foldersRes.data ?? [];
const childFolders = allFolders.filter((f) => (f.parentId ?? null) === parentId);
nodes.push(...childFolders.map((f) => _mapFolderToNode(f, ownership)));
const childFolders = allFolders.filter((f) => (f.parentId ?? null) === apiParentId);
const folderNodes = childFolders.map((f) => _mapFolderToNode(f, ownership));
// Re-parent top-level folders onto the synthetic root.
if (apiParentId === null) {
for (const n of folderNodes) n.parentId = synthRootId;
}
nodes.push(...folderNodes);
try {
const filters: Record<string, any> = {};
if (parentId) {
filters.folderId = parentId;
if (apiParentId) {
filters.folderId = apiParentId;
}
const paginationParam = JSON.stringify({ filters, pageSize: 500 });
const filesRes = await api.get('/api/files/list', {
@ -94,12 +173,16 @@ export function createFolderFileProvider(): TreeNodeProvider {
} else if (Array.isArray(data)) {
rawFiles = data;
}
let matched = rawFiles.filter((f) => (f.folderId ?? null) === parentId);
let matched = rawFiles.filter((f) => (f.folderId ?? null) === apiParentId);
if (ownership === 'shared') {
const myId = getUserDataCache()?.id;
if (myId) matched = matched.filter((f) => f.sysCreatedBy !== myId);
}
nodes.push(...matched.map((f) => _mapFileToNode(f, ownership)));
const fileNodes = matched.map((f) => _mapFileToNode(f, ownership));
if (apiParentId === null) {
for (const n of fileNodes) n.parentId = synthRootId;
}
nodes.push(...fileNodes);
} catch {
// file list may fail for shared trees; folders still render
}
@ -113,15 +196,22 @@ export function createFolderFileProvider(): TreeNodeProvider {
},
canRename(node) {
// Synthetic "/" root cannot be renamed.
if (node.id.startsWith('__filesRoot:')) return false;
return node.ownership === 'own';
},
canDelete(node) {
if (node.id.startsWith('__filesRoot:')) return false;
return node.ownership === 'own';
},
canMove(source, target) {
// The synthetic root itself never moves.
if (source.id.startsWith('__filesRoot:')) return false;
if (source.ownership !== 'own') return false;
// Allow drops onto the synthetic root (= move to top-level).
if (target && target.id.startsWith('__filesRoot:')) return true;
if (target && target.type !== 'folder') return false;
if (target && target.id === source.id) return false;
return true;
@ -136,8 +226,23 @@ export function createFolderFileProvider(): TreeNodeProvider {
},
async createChild(parentId, name) {
const res = await api.post('/api/files/folders', { name, parentId });
return _mapFolderToNode(res.data, 'own');
// Creating a folder under "/" means a top-level folder; map back to null
// for the API. The FE-only synth-root id never travels to the backend.
const apiParentId = parentId && parentId.startsWith('__filesRoot:') ? null : parentId;
const res = await api.post('/api/files/folders', { name, parentId: apiParentId });
const node = _mapFolderToNode(res.data, 'own');
// Bind the new folder visually to the parent the user actually clicked.
// - explicit synth-root parentId -> attach there ("/" + new top-level folder)
// - explicit parent (real folder) -> the API echoes the same parentId
// - parentId === null (no clicked parent, e.g. global "+" with no
// selection): default to the OWN tree's synth-root so the new folder
// shows up inside "/" instead of at the legacy top-level row.
if (parentId && parentId.startsWith('__filesRoot:')) {
node.parentId = parentId;
} else if (parentId === null) {
node.parentId = _SYNTH_ROOT_ID('own');
}
return node;
},
async renameNode(id, newName) {
@ -156,21 +261,29 @@ export function createFolderFileProvider(): TreeNodeProvider {
},
async moveNodes(ids, targetParentId) {
// Synth-root drop = move to top-level (folderId/parentId = null).
const apiTarget = targetParentId && targetParentId.startsWith('__filesRoot:')
? null
: targetParentId;
await Promise.all(
ids.map((id) => {
if (_isFile(id)) return api.put(`/api/files/${id}`, { folderId: targetParentId });
return api.post(`/api/files/folders/${id}/move`, { targetParentId });
if (id.startsWith('__filesRoot:')) return Promise.resolve();
if (_isFile(id)) return api.put(`/api/files/${id}`, { folderId: apiTarget });
return api.post(`/api/files/folders/${id}/move`, { targetParentId: apiTarget });
}),
);
},
async patchScope(ids, scope, cascadeChildren) {
// Synth-root toggle: cascade across every owned top-level folder/file.
const expandedIds = await _expandSyntheticRoots(ids);
await Promise.all(
ids.map((id) => {
expandedIds.map((id) => {
if (_isFile(id)) return api.patch(`/api/files/${id}/scope`, { scope });
return api.patch(`/api/files/folders/${id}/scope`, { scope, cascadeChildren });
return api.patch(`/api/files/folders/${id}/scope`, { scope, cascadeChildren: true });
}),
);
void cascadeChildren;
},
async downloadNode(node) {
@ -185,14 +298,28 @@ export function createFolderFileProvider(): TreeNodeProvider {
},
async patchNeutralize(ids, neutralize) {
const expandedIds = await _expandSyntheticRoots(ids);
await Promise.all(
ids.map((id) => {
expandedIds.map((id) => {
if (_isFile(id)) return api.patch(`/api/files/${id}/neutralize`, { neutralize });
return api.patch(`/api/files/folders/${id}/neutralize`, { neutralize });
}),
);
},
async refreshAttributes(ids: string[]) {
const res = await api.post('/api/files/attributes', { ids });
const raw: Record<string, { neutralize?: boolean | 'mixed'; scope?: string | 'mixed' }> = res.data ?? {};
const result = new Map<string, { neutralize?: boolean | 'mixed'; scope?: ScopeValue | 'mixed' }>();
for (const [id, attrs] of Object.entries(raw)) {
result.set(id, {
neutralize: attrs.neutralize,
scope: attrs.scope as ScopeValue | 'mixed',
});
}
return result;
},
getBatchActions(): TreeBatchAction[] {
return [
{

View file

@ -2,19 +2,50 @@ export type Ownership = 'own' | 'shared';
export type ScopeValue = 'personal' | 'featureInstance' | 'mandate' | 'global';
/** Generic action button rendered to the right of a tree node.
* Tree does not interpret the action key; it only renders icon, tooltip
* and a pending spinner while onClick is running. Provider may set
* value = 'mixed' to make the tree show the uniform mixed symbol instead
* of the icon. */
export interface NodeAction {
key: string;
icon: React.ReactNode;
tooltip: string;
value?: boolean | string | 'mixed';
disabled?: boolean;
onClick?: () => Promise<void> | void;
}
export interface TreeNode<T = any> {
id: string;
name: string;
type: string;
parentId: string | null;
ownership: Ownership;
scope?: ScopeValue;
neutralize?: boolean;
/** Effective scope. 'mixed' means children have differing effective scopes. */
scope?: ScopeValue | 'mixed';
/** Effective neutralize. 'mixed' means children have differing effective values. */
neutralize?: boolean | 'mixed';
/** Effective RAG-index flag. 'mixed' means children have differing effective values. */
ragIndexEnabled?: boolean | 'mixed';
contextOrphan?: boolean;
icon?: React.ReactNode;
children?: TreeNode<T>[];
isLoading?: boolean;
sizeBytes?: number;
/** Optional sort hint. When defined, the node is placed before any sibling
* without a `displayOrder`; among siblings that all carry one, they are
* sorted numerically ascending. When omitted, the default folder-first /
* alphabetic sort applies. Tree-generic; no domain knowledge required. */
displayOrder?: number;
/** When true, the tree auto-expands this node the first time it appears in
* a load result. Subsequent user interactions (collapse/expand) override
* this hint, and re-fetches that re-emit the same id do not re-trigger
* auto-expansion. Tree-generic. */
defaultExpanded?: boolean;
/** Generic extra action buttons. Tree renders them as Icon+Tooltip with
* pending spinner on click. Tree has no knowledge of action semantics. */
extraActions?: NodeAction[];
data?: T;
}
@ -37,14 +68,31 @@ export interface TreeNodeProvider<T = any> {
canMove?(source: TreeNode<T>, target: TreeNode<T> | null): boolean;
canPatchScope?(node: TreeNode<T>): boolean;
canPatchNeutralize?(node: TreeNode<T>): boolean;
canPatchRagIndex?(node: TreeNode<T>): boolean;
createChild?(parentId: string | null, name: string): Promise<TreeNode<T>>;
renameNode?(id: string, newName: string): Promise<void>;
deleteNodes?(ids: string[]): Promise<void>;
moveNodes?(ids: string[], targetParentId: string | null): Promise<void>;
patchScope?(ids: string[], scope: ScopeValue, cascadeChildren?: boolean): Promise<void>;
patchNeutralize?(ids: string[], neutralize: boolean): Promise<void>;
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
* adds provider-specific formats on top. */
customizeDragData?(node: TreeNode<T>, dataTransfer: DataTransfer): void;
}
export interface FormGeneratorTreeProps<T = any> {
@ -62,5 +110,16 @@ export interface FormGeneratorTreeProps<T = any> {
onSendToChat?: (node: TreeNode<T>) => void;
/** When false, hides "Neuer Ordner" (e.g. map from table file permissions). Default true. */
allowCreateFolder?: boolean;
/** 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;
}

View file

@ -34,6 +34,7 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
const [ownTreeKey, setOwnTreeKey] = useState(0);
const [sharedTreeKey, setSharedTreeKey] = useState(0);
const _handleNodeClick = useCallback((node: TreeNode) => {
if (node.type === 'file') {
onFileSelect?.(node.id, node.name);
@ -200,6 +201,7 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
title={t('Eigene')}
compact={true}
showFilter={true}
refreshAfterAction
onNodeClick={_handleNodeClickWithImport}
onSendToChat={_handleSendToChat}
/>
@ -211,6 +213,7 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
compact={true}
collapsible={true}
defaultCollapsed={true}
refreshAfterAction
emptyMessage={t('Keine geteilten Dateien')}
onNodeClick={_handleNodeClickWithImport}
onSendToChat={_handleSendToChat}

View file

@ -1,11 +0,0 @@
.sourcesTab {
height: 100%;
overflow-y: auto;
}
.placeholder {
padding: 16px;
text-align: center;
color: var(--text-secondary, #6b7280);
font-size: 0.85rem;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,455 @@
// Copyright (c) 2026 Patrick Motsch
// All rights reserved.
/**
* 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.
*
* 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`),
* 2. emits stable display ordering via `displayOrder`,
* 3. hides flag affordances on synthetic container nodes (synthRoot,
* mandateGroup) by leaving the corresponding TreeNode field undefined.
*/
import React from 'react';
import {
FaGoogle, FaMicrosoft, FaTasks, FaCloud, FaLink, FaFolder, FaFile, FaEnvelope,
FaCloudUploadAlt, FaCalendarAlt, FaComments, FaUser, FaTable, FaDatabase,
FaBuilding,
} from 'react-icons/fa';
import { SiJira } from 'react-icons/si';
import api from '../../api';
import type { TreeNode, TreeNodeProvider, ScopeValue } from '../FormGenerator/FormGeneratorTree';
// ---------------------------------------------------------------------------
// Backend contract types
// ---------------------------------------------------------------------------
export type UdbBackendKind =
| 'synthRoot'
| 'connection' | 'service' | 'folder' | 'file'
| 'mandateGroup' | 'featureNode' | 'fdsTable' | 'fdsRecord' | 'fdsField';
export interface UdbBackendNode {
key: string;
kind: UdbBackendKind;
parentKey: string | null;
label: string;
icon?: string;
hasChildren: boolean;
dataSourceId: string | null;
modelType: 'DataSource' | 'FeatureDataSource' | null;
effectiveNeutralize: boolean | 'mixed';
effectiveScope: string;
effectiveRagIndexEnabled: boolean | 'mixed';
supportsRag: boolean;
canBeAdded: boolean;
displayOrder?: number;
defaultExpanded?: boolean;
authority?: string;
connectionId?: string;
service?: string;
sourceType?: string;
path?: string;
featureInstanceId?: string;
featureCode?: string;
mandateId?: string;
tableName?: string;
fieldName?: string;
objectKey?: string;
displayPath?: string;
/** fdsTable-only: persisted list of column names to neutralize (PII mask). */
neutralizeFields?: string[];
}
/** Kinds that represent the *root* of a data source (one DataSource record per
* node). Settings are only meaningful here; folders/files/services/tables
* inherit settings from the root and don't get their own gear icon. */
const _DATA_SOURCE_ROOT_KINDS = new Set<UdbBackendKind>([
'connection',
'featureNode',
]);
// ---------------------------------------------------------------------------
// Icon resolution (kept inline; UDB-domain mapping, not Tree-generic)
// ---------------------------------------------------------------------------
const _AUTHORITY_ICONS: Record<string, React.ReactNode> = {
msft: <FaMicrosoft style={{ color: '#00a4ef', fontSize: 12 }} />,
google: <FaGoogle style={{ color: '#4285f4', fontSize: 12 }} />,
clickup: <FaTasks style={{ color: '#7b68ee', fontSize: 12 }} />,
infomaniak: <FaCloud style={{ color: '#0098db', fontSize: 12 }} />,
'local:ftp': <FaLink style={{ color: '#795548', fontSize: 12 }} />,
'local:jira': <SiJira style={{ color: '#0052CC', fontSize: 12 }} />,
};
const _SERVICE_ICONS: Record<string, React.ReactNode> = {
sharepoint: <FaFolder style={{ color: '#0078d4', fontSize: 11 }} />,
onedrive: <FaCloudUploadAlt style={{ color: '#0078d4', fontSize: 11 }} />,
outlook: <FaEnvelope style={{ color: '#0078d4', fontSize: 11 }} />,
teams: <FaComments style={{ color: '#6264a7', fontSize: 11 }} />,
drive: <FaCloudUploadAlt style={{ color: '#4285f4', fontSize: 11 }} />,
gmail: <FaEnvelope style={{ color: '#ea4335', fontSize: 11 }} />,
files: <FaLink style={{ color: '#795548', fontSize: 11 }} />,
kdrive: <FaCloudUploadAlt style={{ color: '#0098db', fontSize: 11 }} />,
calendar: <FaCalendarAlt style={{ color: '#888', fontSize: 11 }} />,
contact: <FaUser style={{ color: '#888', fontSize: 11 }} />,
};
const _KIND_FALLBACK_ICONS: Record<string, React.ReactNode> = {
synthRoot: <FaDatabase style={{ color: '#666', fontSize: 12 }} />,
connection: <FaLink style={{ color: '#888', fontSize: 12 }} />,
service: <FaFolder style={{ color: '#888', fontSize: 11 }} />,
folder: <FaFolder style={{ color: '#888', fontSize: 11 }} />,
file: <FaFile style={{ color: '#888', fontSize: 11 }} />,
mandateGroup: <FaBuilding style={{ color: '#7b1fa2', fontSize: 12 }} />,
featureNode: <FaDatabase style={{ color: '#7b1fa2', fontSize: 11 }} />,
fdsTable: <FaTable style={{ color: '#7b1fa2', fontSize: 11 }} />,
fdsRecord: <FaFile style={{ color: '#7b1fa2', fontSize: 11 }} />,
fdsField: <span style={{ color: '#9e9e9e', fontSize: 10, fontFamily: 'monospace' }}>{'\u22EE'}</span>,
};
function _renderIcon(node: UdbBackendNode): React.ReactNode {
if (node.kind === 'connection') {
return _AUTHORITY_ICONS[node.icon || ''] ?? _KIND_FALLBACK_ICONS.connection;
}
if (node.kind === 'service') {
return _SERVICE_ICONS[node.icon || ''] ?? _KIND_FALLBACK_ICONS.service;
}
return _KIND_FALLBACK_ICONS[node.kind] ?? null;
}
// ---------------------------------------------------------------------------
// Domain rule: which kinds expose flag toggles
// ---------------------------------------------------------------------------
/** Synthetic / structural containers carry no DB record and have no flags.
* The provider hides scope/neutralize/ragIndexEnabled for them so the tree
* doesn't render dead buttons. */
function _isSyntheticContainer(kind: UdbBackendKind): boolean {
return kind === 'synthRoot' || kind === 'mandateGroup';
}
// ---------------------------------------------------------------------------
// Mapping: backend payload -> generic TreeNode
// ---------------------------------------------------------------------------
function _mapBackendNode(
n: UdbBackendNode,
onSettingsClick: (n: UdbBackendNode) => Promise<void> | void,
): TreeNode<UdbBackendNode> {
const isSynthetic = _isSyntheticContainer(n.kind);
const isFolderLike = n.hasChildren;
const node: TreeNode<UdbBackendNode> = {
id: n.key,
name: n.label,
type: isFolderLike ? 'folder' : 'file',
parentId: n.parentKey,
ownership: 'own',
icon: _renderIcon(n),
displayOrder: n.displayOrder,
defaultExpanded: n.defaultExpanded,
data: n,
};
if (!isSynthetic) {
if (n.kind === 'fdsField') {
// Fields expose ONLY neutralize (mapped to parent table's
// neutralizeFields list). Scope and RAG are not field-level concepts.
node.neutralize = n.effectiveNeutralize;
} else {
node.scope = n.effectiveScope as ScopeValue | 'mixed';
node.neutralize = n.effectiveNeutralize;
if (n.supportsRag) {
node.ragIndexEnabled = n.effectiveRagIndexEnabled;
}
}
}
if (_DATA_SOURCE_ROOT_KINDS.has(n.kind)) {
node.extraActions = [{
key: 'settings',
icon: '\u2699\uFE0F',
tooltip: 'Einstellungen',
onClick: () => onSettingsClick(n),
}];
}
return node;
}
// ---------------------------------------------------------------------------
// Provider factory
// ---------------------------------------------------------------------------
export interface UdbSourcesProviderHandle extends TreeNodeProvider<UdbBackendNode> {
/** Test/diagnostic hook only -- exposes the latest cached backend payloads
* so consumers can inspect data flow without round-tripping through the
* network. Not part of the contract used at runtime. */
_diagnosticGetCacheSize(): number;
}
export function createUdbSourcesProvider(
instanceId: string,
onOpenSettings: (dataSourceId: string, label: string) => void,
): UdbSourcesProviderHandle {
// Per-id cache of the most recent backend payload. Updated by every
// `loadChildren` call. Read by patch/ensureRecord paths.
const nodeCache = new Map<string, UdbBackendNode>();
async function _ensureRecord(node: UdbBackendNode): Promise<string | null> {
if (node.dataSourceId) return node.dataSourceId;
try {
if (node.kind === 'connection' || node.kind === 'service'
|| node.kind === 'folder' || node.kind === 'file') {
const sourceType = node.sourceType
|| (node.kind === 'connection' ? node.authority : '')
|| '';
const res = await api.post(`/api/workspace/${instanceId}/datasources`, {
connectionId: node.connectionId || '',
sourceType,
path: node.path || '/',
label: node.label,
displayPath: node.displayPath || node.label,
});
const newId: string | null = res.data?.id ?? null;
if (newId) {
nodeCache.set(node.key, { ...node, dataSourceId: newId, modelType: 'DataSource' });
}
return newId;
}
if (node.kind === 'featureNode' || node.kind === 'fdsTable' || node.kind === 'fdsRecord') {
const tableName = node.tableName || (node.kind === 'featureNode' ? '*' : '');
const objectKey = node.objectKey
|| (node.kind === 'featureNode' ? `data.feature.${node.featureCode}.*` : '');
const res = await api.post(`/api/workspace/${instanceId}/feature-datasources`, {
featureInstanceId: node.featureInstanceId || '',
featureCode: node.featureCode || '',
tableName,
objectKey,
label: node.label,
});
const newId: string | null = res.data?.id ?? null;
if (newId) {
nodeCache.set(node.key, { ...node, dataSourceId: newId, modelType: 'FeatureDataSource' });
}
return newId;
}
} catch (err) {
console.error('[UdbSourcesProvider] ensureRecord failed', err);
}
return null;
}
async function _onSettingsClick(node: UdbBackendNode): Promise<void> {
const dsId = await _ensureRecord(node);
if (!dsId) {
console.warn('[UdbSourcesProvider] settings click: cannot ensure record', node.key);
return;
}
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;
}
}
async function _patchFlag(
ids: string[],
flag: 'scope' | 'neutralize' | 'rag-index',
body: Record<string, 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;
try {
await api.patch(`/api/datasources/${dsId}/${flag}`, body);
} catch (err) {
console.error('[UdbSourcesProvider] patch failed', { id, flag, err });
throw err;
}
}
}
return {
rootKey: `udb-sources-${instanceId}`,
async loadChildren(parentId, _ownership) {
const res = await api.post(`/api/workspace/${instanceId}/tree/children`, {
parents: [parentId],
});
const nodesByParent = res.data?.nodesByParent || {};
const lookupKey = parentId ?? '__root__';
const list: UdbBackendNode[] = nodesByParent[lookupKey] || [];
for (const n of list) nodeCache.set(n.key, n);
return list.map((n) => _mapBackendNode(n, _onSettingsClick));
},
canPatchScope(node) {
const data = node.data;
// Field-level scope makes no sense; it's inherited from the parent table.
return !!data && !_isSyntheticContainer(data.kind) && data.kind !== 'fdsField';
},
canPatchNeutralize(node) {
const data = node.data;
return !!data && !_isSyntheticContainer(data.kind);
},
canPatchRagIndex(node) {
const data = node.data;
// RAG is not a field-level concept either; only the table-record carries it.
return !!data && data.supportsRag === true && data.kind !== 'fdsField';
},
async patchScope(ids, scope, _cascadeChildren) {
// Backend cascades NULL on descendants automatically based on the
// existence of explicit child records; the cascadeChildren flag is the
// FilesTab convention and is irrelevant here.
await _patchFlag(ids, 'scope', { scope });
},
async patchNeutralize(ids, neutralize) {
// 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);
},
async patchRagIndex(ids, ragIndexEnabled) {
await _patchFlag(ids, 'rag-index', { ragIndexEnabled });
},
customizeDragData(node, dataTransfer) {
const data = node.data as UdbBackendNode | undefined;
if (!data || _isSyntheticContainer(data.kind)) return;
if (data.kind === 'connection' || data.kind === 'service'
|| data.kind === 'folder' || data.kind === 'file') {
const sourceType = data.sourceType
|| (data.kind === 'connection' ? data.authority : '') || '';
const payload = {
connectionId: data.connectionId || '',
sourceType,
path: data.path || '/',
label: data.label,
displayPath: data.displayPath || data.label,
};
dataTransfer.setData('application/datasource', JSON.stringify(payload));
} else if (data.kind === 'featureNode' || data.kind === 'fdsTable' || data.kind === 'fdsRecord') {
const tableName = data.tableName || (data.kind === 'featureNode' ? '*' : '');
const objectKey = data.objectKey
|| (data.kind === 'featureNode' ? `data.feature.${data.featureCode}.*` : '');
const payload = {
featureInstanceId: data.featureInstanceId || '',
featureCode: data.featureCode || '',
tableName,
objectKey,
label: data.label,
};
dataTransfer.setData('application/feature-source', JSON.stringify(payload));
}
},
async refreshAttributes(ids: string[]) {
const res = await api.post(`/api/workspace/${instanceId}/tree/attributes`, {
keys: ids,
});
const raw: Record<string, {
effectiveNeutralize?: boolean | 'mixed';
effectiveScope?: string | 'mixed';
effectiveRagIndexEnabled?: boolean | 'mixed';
}> = res.data?.attributes ?? {};
const result = new Map<string, {
neutralize?: boolean | 'mixed';
scope?: ScopeValue | 'mixed';
ragIndexEnabled?: boolean | 'mixed';
}>();
for (const [key, attrs] of Object.entries(raw)) {
result.set(key, {
neutralize: attrs.effectiveNeutralize,
scope: attrs.effectiveScope as ScopeValue | 'mixed',
ragIndexEnabled: attrs.effectiveRagIndexEnabled,
});
}
return result;
},
_diagnosticGetCacheSize() {
return nodeCache.size;
},
};
}

View file

@ -0,0 +1,384 @@
// Copyright (c) 2026 Patrick Motsch
// All rights reserved.
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { createUdbSourcesProvider, type UdbBackendNode } from '../UdbSourcesProvider';
// Mock the api module that the provider imports.
vi.mock('../../../api', () => ({
default: {
post: vi.fn(),
patch: vi.fn(),
},
}));
import api from '../../../api';
const apiMock = api as unknown as { post: ReturnType<typeof vi.fn>; patch: ReturnType<typeof vi.fn> };
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
function _makeBackendNode(overrides: Partial<UdbBackendNode> = {}): UdbBackendNode {
return {
key: 'conn|c1',
kind: 'connection',
parentKey: 'personalRoot',
label: 'My Microsoft',
icon: 'msft',
hasChildren: true,
dataSourceId: null,
modelType: null,
effectiveNeutralize: false,
effectiveScope: 'personal',
effectiveRagIndexEnabled: false,
supportsRag: true,
canBeAdded: true,
authority: 'msft',
connectionId: 'c1',
...overrides,
};
}
function _makeSynthRootNode(): UdbBackendNode {
return {
key: 'personalRoot',
kind: 'synthRoot',
parentKey: null,
label: 'Persoenliche Quellen',
icon: 'person',
hasChildren: true,
dataSourceId: null,
modelType: null,
effectiveNeutralize: false,
effectiveScope: 'personal',
effectiveRagIndexEnabled: false,
supportsRag: false,
canBeAdded: false,
displayOrder: 0,
defaultExpanded: true,
};
}
const _instanceId = 'inst-42';
beforeEach(() => {
apiMock.post.mockReset();
apiMock.patch.mockReset();
});
// ---------------------------------------------------------------------------
// loadChildren
// ---------------------------------------------------------------------------
describe('UdbSourcesProvider.loadChildren', () => {
it('calls POST /api/workspace/{instanceId}/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`,
{ parents: [null] },
);
});
it('maps backend nodes to TreeNode shape with flag-bearer fields', async () => {
const conn = _makeBackendNode();
apiMock.post.mockResolvedValue({ data: { nodesByParent: { 'personalRoot': [conn] } } });
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
const result = await provider.loadChildren('personalRoot', 'own');
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 () => {
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);
});
});
// ---------------------------------------------------------------------------
// canPatch* predicates
// ---------------------------------------------------------------------------
describe('UdbSourcesProvider.canPatch*', () => {
it('canPatchScope is false for synthetic containers', async () => {
apiMock.post.mockResolvedValue({
data: { nodesByParent: { __root__: [_makeSynthRootNode()] } },
});
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
const [synthNode] = await provider.loadChildren(null, 'own');
expect(provider.canPatchScope?.(synthNode)).toBe(false);
expect(provider.canPatchNeutralize?.(synthNode)).toBe(false);
expect(provider.canPatchRagIndex?.(synthNode)).toBe(false);
});
it('canPatchRagIndex requires supportsRag=true', async () => {
apiMock.post.mockResolvedValue({
data: {
nodesByParent: {
personalRoot: [
_makeBackendNode({ key: 'a', supportsRag: true }),
_makeBackendNode({ key: 'b', supportsRag: false }),
],
},
},
});
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);
});
});
// ---------------------------------------------------------------------------
// patch flow: ensureRecord + PATCH
// ---------------------------------------------------------------------------
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: {} });
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' },
);
// 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: {} });
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 },
);
});
});
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: {} });
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 },
);
});
});

View file

@ -0,0 +1,88 @@
// Copyright (c) 2026 Patrick Motsch
// All rights reserved.
/**
* useTreeExpansion - fire-and-forget persistence for tree expand state.
*
* Simple contract:
* - On mount: load saved expandedIds from backend (or null if none).
* - Returns the loaded ids (once) so the tree can seed its initial state.
* - Provides a `save(ids)` function that debounce-PUTs to the backend.
* - No bidirectional state flow, no props, no re-render triggers.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import api from '../api';
const _SAVE_DEBOUNCE_MS = 600;
export interface UseTreeExpansionResult {
loaded: boolean;
initialIds: string[] | null;
save: (ids: string[]) => void;
}
export function useTreeExpansion(
instanceId: string | null | undefined,
scope: string,
): UseTreeExpansionResult {
const [loaded, setLoaded] = useState(false);
const [initialIds, setInitialIds] = useState<string[] | null>(null);
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const latestRef = useRef<string[] | null>(null);
useEffect(() => {
if (!instanceId) {
setLoaded(true);
setInitialIds(null);
return;
}
let cancelled = false;
setLoaded(false);
api
.get(`/api/workspace/${instanceId}/ui-tree-expansion/${encodeURIComponent(scope)}`)
.then((res) => {
if (cancelled) return;
const fromServer: string[] | null = res.data?.expandedNodes ?? null;
setInitialIds(fromServer);
latestRef.current = fromServer;
setLoaded(true);
})
.catch((err) => {
if (cancelled) return;
console.warn('[useTreeExpansion] load failed', err);
setInitialIds(null);
setLoaded(true);
});
return () => { cancelled = true; };
}, [instanceId, scope]);
const save = useCallback(
(ids: string[]) => {
if (!instanceId) return;
const sorted = [...ids].sort().join('|');
const prevSorted = latestRef.current ? [...latestRef.current].sort().join('|') : null;
if (sorted === prevSorted) return;
latestRef.current = ids;
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(() => {
api
.put(
`/api/workspace/${instanceId}/ui-tree-expansion/${encodeURIComponent(scope)}`,
{ expandedNodes: latestRef.current ?? [] },
)
.catch((err) => {
console.warn('[useTreeExpansion] save failed', err);
});
}, _SAVE_DEBOUNCE_MS);
},
[instanceId, scope],
);
useEffect(() => {
return () => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
};
}, []);
return { loaded, initialIds, save };
}

View file

@ -131,6 +131,16 @@
gap: 16px;
}
/* ── Section title ── */
.sectionTitle {
display: flex;
align-items: center;
font-size: 1rem;
font-weight: 600;
color: var(--color-text-secondary, #6b7280);
margin: 8px 0 0;
}
/* ── Connection Card ── */
.connectionCard {
border: 1px solid var(--color-border, #e5e7eb);

View file

@ -10,9 +10,8 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useLanguage } from '../providers/language/LanguageContext';
import { useApiRequest } from '../hooks/useApi';
import { useUserMandates } from '../hooks/useUserMandates';
import type { RagInventoryDto, RagConnectionDto } from '../api/connectionApi';
import { FaDatabase, FaStop, FaSync, FaToggleOn, FaToggleOff, FaRedo, FaExclamationTriangle, FaCheckCircle, FaSlidersH } from 'react-icons/fa';
import type { RagInventoryDto, RagConnectionDto, RagFeatureInstanceDto } from '../api/connectionApi';
import { FaDatabase, FaStop, FaSync, FaToggleOn, FaToggleOff, FaRedo, FaExclamationTriangle, FaCheckCircle, FaSlidersH, FaCubes } from 'react-icons/fa';
import { mandateDisplayLabel } from '../utils/mandateDisplayUtils';
import { DataSourceSettingsModal } from '../components/UnifiedDataBar/DataSourceSettingsModal';
import styles from './RagInventoryPage.module.css';
@ -20,7 +19,6 @@ import styles from './RagInventoryPage.module.css';
export const RagInventoryPage: React.FC = () => {
const { t } = useLanguage();
const { request } = useApiRequest();
const { fetchMandates } = useUserMandates();
const [mandates, setMandates] = useState<any[]>([]);
const [mandatesLoading, setMandatesLoading] = useState(true);
@ -54,7 +52,7 @@ export const RagInventoryPage: React.FC = () => {
(async () => {
setMandatesLoading(true);
try {
const data = await fetchMandates();
const data = await request({ url: '/api/rag/inventory/my-mandates', method: 'get' });
if (!cancelled) {
const list = Array.isArray(data) ? data : [];
setMandates(list);
@ -64,7 +62,7 @@ export const RagInventoryPage: React.FC = () => {
finally { if (!cancelled) setMandatesLoading(false); }
})();
return () => { cancelled = true; };
}, [fetchMandates]);
}, [request]);
const _apiEndpoint = useMemo(() => {
if (selectedScope === 'personal') return '/api/rag/inventory/me';
@ -77,11 +75,13 @@ export const RagInventoryPage: React.FC = () => {
setError(null);
try {
const params: Record<string, string> = {};
if (selectedScope !== 'personal' && selectedScope !== 'platform') {
params.mandateId = selectedScope;
}
if (onlyMyData) params.onlyMine = 'true';
const data = await request({ url: _apiEndpoint, method: 'get', params });
const isMandateScope = selectedScope !== 'personal' && selectedScope !== 'platform';
const headers: Record<string, string> = {};
if (isMandateScope) {
headers['X-Mandate-Id'] = selectedScope;
}
const data = await request({ url: _apiEndpoint, method: 'get', params, additionalConfig: { headers } });
setInventory(data);
} catch (err: any) {
if (err?.message?.includes('403')) {
@ -99,7 +99,10 @@ export const RagInventoryPage: React.FC = () => {
_fetchInventory();
}, [_fetchInventory]);
const _hasActiveJobs = !!inventory?.connections?.some(c => (c.runningJobs?.length || 0) > 0);
const _hasActiveJobs = !!(
inventory?.connections?.some(c => (c.runningJobs?.length || 0) > 0) ||
inventory?.featureInstances?.some(fi => (fi.runningJobs?.length || 0) > 0)
);
useEffect(() => {
if (pollRef.current) clearInterval(pollRef.current);
@ -127,6 +130,13 @@ export const RagInventoryPage: React.FC = () => {
} catch {}
};
const _handleReindexFeature = async (workspaceInstanceId: string) => {
try {
await request({ url: `/api/rag/inventory/reindex-feature/${workspaceInstanceId}`, method: 'post' });
_fetchInventory();
} catch {}
};
const _handleConsentToggle = async (connectionId: string, currentEnabled: boolean) => {
if (!currentEnabled || window.confirm(t('Alle indexierten Inhalte dieser Verbindung werden entfernt. Fortfahren?'))) {
try {
@ -374,7 +384,118 @@ export const RagInventoryPage: React.FC = () => {
</div>
))}
{(inventory.connections || []).length === 0 && (
{(inventory.featureInstances || []).length > 0 && (
<>
<h2 className={styles.sectionTitle}>
<FaCubes style={{ marginRight: 8 }} />
{t('Feature-Daten')}
</h2>
{(inventory.featureInstances || []).map((fi: RagFeatureInstanceDto) => {
const runningJobs = fi.runningJobs || [];
const lastSuccess = fi.lastSuccess;
const lastError = fi.lastError;
return (
<div key={fi.featureInstanceId} className={styles.connectionCard}>
<div className={styles.connectionHeader}>
<span className={styles.authority}>{fi.featureCode}</span>
<span className={styles.email}>{fi.label}</span>
{(fi.fileCount > 0 || fi.chunkCount > 0) && (
<span
className={styles.connChunks}
title={t('Embedding-Fragmente (~400 Tokens), die der RAG-Retrieval trifft')}
>
{t('{f} Dateien · {c} Chunks', { f: fi.fileCount, c: fi.chunkCount })}
</span>
)}
<span className={styles.dsIndex} title={fi.ragEnabled ? t('RAG aktiv') : t('RAG inaktiv')}>
{fi.ragEnabled ? '\uD83E\uDDE0' : '\u2014'}
</span>
</div>
{!fi.ragEnabled && (fi.dataSources || []).length > 0 && (
<div className={styles.consentWarning}>
{t('RAG-Indexierung ist für keine Datenquelle dieser Feature-Instanz aktiviert. Aktivierung erfolgt in der UDB (Unified Data Bar) der jeweiligen Workspace-Sitzung.')}
</div>
)}
{runningJobs.length > 0 ? (
<div className={styles.jobBanner}>
<FaSync className={styles.spinIcon} />
<span>{runningJobs[0].progressMessage || t('Feature-Daten werden synchronisiert...')}</span>
</div>
) : (() => {
const errAt = lastError?.finishedAt ?? 0;
const okAt = lastSuccess?.finishedAt ?? 0;
const errorIsNewer = !!lastError && errAt > okAt;
if (errorIsNewer) {
return (
<div className={styles.errorBanner}>
<FaExclamationTriangle />
<span>
{t('Letzter Sync fehlgeschlagen')} ({_formatRelative(errAt)}): {lastError?.errorMessage || t('unbekannter Fehler')}
</span>
<button className={styles.reindexBtn} onClick={() => _handleReindexFeature(fi.featureInstanceId)} title={t('Neu indexieren')}>
<FaRedo size={12} /> {t('Neu indexieren')}
</button>
</div>
);
}
if (lastSuccess) {
const s = lastSuccess;
const stats = [
s.indexed > 0 ? t('{n} neu indexiert', { n: s.indexed }) : null,
s.skippedDuplicate > 0 ? t('{n} unverändert', { n: s.skippedDuplicate }) : null,
s.failed > 0 ? t('{n} fehler', { n: s.failed }) : null,
].filter(Boolean).join(' · ');
return (
<div className={styles.successBanner}>
<FaCheckCircle />
<span>
{t('Sync erfolgreich')} {_formatRelative(okAt)}
{stats && <> {stats}</>}
</span>
<button className={styles.reindexBtn} onClick={() => _handleReindexFeature(fi.featureInstanceId)} title={t('Erneut indexieren')}>
<FaRedo size={12} /> {t('Erneut indexieren')}
</button>
</div>
);
}
if (fi.ragEnabled) {
return (
<div className={styles.reindexHint}>
<button className={styles.reindexBtn} onClick={() => _handleReindexFeature(fi.featureInstanceId)} title={t('Indexierung starten')}>
<FaRedo size={12} /> {t('Indexierung starten')}
</button>
</div>
);
}
return null;
})()}
<div className={styles.dsList}>
{(fi.dataSources || []).map(ds => (
<div key={ds.id} className={`${styles.dsRow} ${ds.ragIndexEnabled ? styles.dsActive : ''}`}>
<span className={styles.dsLabel}>{ds.label || ds.tableName}</span>
<span className={styles.dsType}>{ds.featureCode}</span>
<span className={styles.dsIndex}>{ds.ragIndexEnabled ? '\uD83E\uDDE0' : '\u2014'}</span>
</div>
))}
{(fi.dataSources || []).length === 0 && fi.fileCount === 0 && (
<div className={styles.dsEmpty}>{t('Keine Datenquellen konfiguriert')}</div>
)}
</div>
</div>
);
})}
</>
)}
{(inventory.connections || []).length === 0 && (inventory.featureInstances || []).length === 0 && (
<div className={styles.emptyState}>{t('Keine Daten für diese Sicht vorhanden.')}</div>
)}
</div>

View file

@ -97,7 +97,9 @@ export const AdminDemoConfigPage: React.FC = () => {
setActionInProgress(code);
setLastResult(null);
try {
const response = await api.post(`/api/admin/demo-config/${code}/remove`);
const response = await api.post(`/api/admin/demo-config/${code}/remove`, null, {
headers: { 'X-Confirm-Destructive': 'true' },
});
setLastResult({ code, action: 'remove', status: 'ok', summary: response.data.summary });
} catch (err: any) {
setLastResult({ code, action: 'remove', status: 'error', error: err.response?.data?.detail || String(err) });

View file

@ -11,6 +11,7 @@
import React, { useRef, useEffect, useCallback, useState } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { FaRegCopy, FaCheck } from 'react-icons/fa';
import api from '../../../api';
import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
import type { Message, MessageDocument } from '../../../components/UiComponents/Messages/MessagesTypes';
@ -41,6 +42,14 @@ export const ChatStream: React.FC<ChatStreamProps> = ({ messages,
const bottomRef = useRef<HTMLDivElement>(null);
const audioQueue = useAudioQueue();
const enqueuedIdsRef = useRef<Set<string>>(new Set());
const [copiedId, setCopiedId] = useState<string | null>(null);
const _handleCopy = useCallback((msgId: string, text: string) => {
navigator.clipboard.writeText(text).then(() => {
setCopiedId(msgId);
setTimeout(() => setCopiedId((prev) => (prev === msgId ? null : prev)), 1500);
}).catch(() => {});
}, []);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
@ -92,7 +101,25 @@ export const ChatStream: React.FC<ChatStreamProps> = ({ messages,
}}
>
{msg.role === 'assistant' && (
<div style={{ fontSize: 11, color: '#888', marginBottom: 4 }}>Assistant</div>
<div style={{ fontSize: 11, color: '#888', marginBottom: 4, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span>Assistant</span>
{msg.message && (
<button
onClick={() => _handleCopy(msg.id, msg.message!)}
title={copiedId === msg.id ? t('Kopiert') : t('In Zwischenablage kopieren')}
style={{
background: 'none', border: 'none', cursor: 'pointer',
padding: '2px 4px', borderRadius: 4, display: 'flex', alignItems: 'center',
color: copiedId === msg.id ? 'var(--success-color, #4caf50)' : '#aaa',
transition: 'color 0.2s',
}}
onMouseEnter={e => { if (copiedId !== msg.id) e.currentTarget.style.color = '#666'; }}
onMouseLeave={e => { if (copiedId !== msg.id) e.currentTarget.style.color = '#aaa'; }}
>
{copiedId === msg.id ? <FaCheck size={12} /> : <FaRegCopy size={12} />}
</button>
)}
</div>
)}
{msg.role === 'status' ? (
<span>{msg.message}</span>
@ -648,6 +675,15 @@ function _CodeBlock({
}: React.HTMLAttributes<HTMLElement> & { inline?: boolean }) {
const match = /language-(\w+)/.exec(className || '');
const isInline = !match && !String(children).includes('\n');
const [copied, setCopied] = useState(false);
const _copyCode = useCallback(() => {
const text = String(children).replace(/\n$/, '');
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}).catch(() => {});
}, [children]);
if (isInline) {
return (
@ -668,15 +704,32 @@ function _CodeBlock({
return (
<div style={{ position: 'relative', margin: '8px 0' }}>
{match && (
<div style={{
position: 'absolute', top: 0, right: 0,
padding: '2px 8px', fontSize: 10, color: '#888',
background: '#2d2d2d', borderBottomLeftRadius: 4,
}}>
{match[1]}
</div>
)}
<div style={{
position: 'absolute', top: 0, right: 0,
display: 'flex', alignItems: 'center', gap: 4,
background: '#2d2d2d', borderBottomLeftRadius: 4,
padding: '2px 4px',
}}>
{match && (
<span style={{ fontSize: 10, color: '#888', padding: '0 4px' }}>
{match[1]}
</span>
)}
<button
onClick={_copyCode}
title={copied ? 'Kopiert' : 'Kopieren'}
style={{
background: 'none', border: 'none', cursor: 'pointer',
padding: '2px 6px', display: 'flex', alignItems: 'center',
color: copied ? '#4caf50' : '#888',
transition: 'color 0.2s',
}}
onMouseEnter={e => { if (!copied) e.currentTarget.style.color = '#ccc'; }}
onMouseLeave={e => { if (!copied) e.currentTarget.style.color = '#888'; }}
>
{copied ? <FaCheck size={11} /> : <FaRegCopy size={11} />}
</button>
</div>
<pre style={{
background: '#1e1e1e',
color: '#d4d4d4',

View file

@ -202,6 +202,19 @@ export const WorkspaceInput = forwardRef<WorkspaceInputHandle, WorkspaceInputPro
onPendingAttachFdsConsumed?.();
}, [pendingAttachFdsId, onPendingAttachFdsConsumed, _persistAttachments, attachedDataSourceIds]);
const _prevWorkflowId = useRef<string | null | undefined>(undefined);
useEffect(() => {
if (_prevWorkflowId.current === undefined) {
_prevWorkflowId.current = workflowId ?? null;
return;
}
const wasNull = !_prevWorkflowId.current;
_prevWorkflowId.current = workflowId ?? null;
if (wasNull && workflowId && (attachedDataSourceIds.length > 0 || attachedFeatureDataSourceIds.length > 0)) {
_persistAttachments(attachedDataSourceIds, attachedFeatureDataSourceIds);
}
}, [workflowId, _persistAttachments, attachedDataSourceIds, attachedFeatureDataSourceIds]);
useEffect(() => {
if (loadedNonce === undefined) return;
setAttachments([]);
@ -534,7 +547,6 @@ export const WorkspaceInput = forwardRef<WorkspaceInputHandle, WorkspaceInputPro
const chatId = e.dataTransfer.getData('application/chat-id');
if (chatId) {
e.preventDefault();
e.stopPropagation();
const chatLabel = e.dataTransfer.getData('text/plain');
const refLabel = chatLabel ? `[Chat: ${chatLabel}]` : `[Chat: ${chatId.slice(0, 8)}]`;
setPrompt(prev => (prev ? `${prev} ${refLabel}` : refLabel));
@ -544,7 +556,6 @@ export const WorkspaceInput = forwardRef<WorkspaceInputHandle, WorkspaceInputPro
const featureSourceJson = e.dataTransfer.getData('application/feature-source');
if (featureSourceJson && onFeatureSourceDrop) {
e.preventDefault();
e.stopPropagation();
const params = JSON.parse(featureSourceJson);
onFeatureSourceDrop(params);
return;
@ -553,7 +564,6 @@ export const WorkspaceInput = forwardRef<WorkspaceInputHandle, WorkspaceInputPro
const dataSourceJson = e.dataTransfer.getData('application/datasource');
if (dataSourceJson && onDataSourceDrop) {
e.preventDefault();
e.stopPropagation();
const params = JSON.parse(dataSourceJson);
onDataSourceDrop(params);
return;
@ -562,7 +572,6 @@ export const WorkspaceInput = forwardRef<WorkspaceInputHandle, WorkspaceInputPro
const handled = await _ingestDataTransfer(e.dataTransfer);
if (handled) {
e.preventDefault();
e.stopPropagation();
textareaRef.current?.focus();
}
}, [_ingestDataTransfer, onFeatureSourceDrop, onDataSourceDrop]);

View file

@ -209,12 +209,15 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
}, [_isCenterDropInteresting]);
const _handleDrop = useCallback(async (e: React.DragEvent) => {
const alreadyHandled = e.defaultPrevented;
e.preventDefault();
e.stopPropagation();
dragCounterRef.current = 0;
setIsDragOver(false);
await _consumeDataTransferFilesOrChat(e.dataTransfer);
if (!alreadyHandled) {
await _consumeDataTransferFilesOrChat(e.dataTransfer);
}
}, [_consumeDataTransferFilesOrChat]);
const _handleFileInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {

View file

@ -2,6 +2,7 @@
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.test.json" }
]
}