From 1308e6d415de5d8de5549be7463329435fb7d12a Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Tue, 19 May 2026 16:47:52 +0200 Subject: [PATCH] fixes rag and workflow --- src/api/connectionApi.ts | 43 +- .../FormGeneratorTree.module.css | 23 + .../FormGeneratorTree/FormGeneratorTree.tsx | 419 ++- .../__tests__/FormGeneratorTree.test.tsx | 428 ++- .../FormGeneratorTree/__tests__/dnd.test.tsx | 21 + .../providers/FolderFileProvider.tsx | 155 +- .../FormGenerator/FormGeneratorTree/types.ts | 63 +- src/components/UnifiedDataBar/FilesTab.tsx | 3 + .../UnifiedDataBar/SourcesTab.module.css | 11 - src/components/UnifiedDataBar/SourcesTab.tsx | 2544 +---------------- .../UnifiedDataBar/UdbSourcesProvider.tsx | 455 +++ .../__tests__/UdbSourcesProvider.test.ts | 384 +++ src/hooks/useTreeExpansion.ts | 88 + src/pages/RagInventoryPage.module.css | 10 + src/pages/RagInventoryPage.tsx | 145 +- src/pages/admin/AdminDemoConfigPage.tsx | 4 +- src/pages/views/workspace/ChatStream.tsx | 73 +- src/pages/views/workspace/WorkspaceInput.tsx | 17 +- src/pages/views/workspace/WorkspacePage.tsx | 5 +- tsconfig.json | 3 +- 20 files changed, 2272 insertions(+), 2622 deletions(-) delete mode 100644 src/components/UnifiedDataBar/SourcesTab.module.css create mode 100644 src/components/UnifiedDataBar/UdbSourcesProvider.tsx create mode 100644 src/components/UnifiedDataBar/__tests__/UdbSourcesProvider.test.ts create mode 100644 src/hooks/useTreeExpansion.ts diff --git a/src/api/connectionApi.ts b/src/api/connectionApi.ts index 27ee41e..e5db443 100644 --- a/src/api/connectionApi.ts +++ b/src/api/connectionApi.ts @@ -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 { 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; + 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 }; } diff --git a/src/components/FormGenerator/FormGeneratorTree/FormGeneratorTree.module.css b/src/components/FormGenerator/FormGeneratorTree/FormGeneratorTree.module.css index e0ab989..1c3fba4 100644 --- a/src/components/FormGenerator/FormGeneratorTree/FormGeneratorTree.module.css +++ b/src/components/FormGenerator/FormGeneratorTree/FormGeneratorTree.module.css @@ -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; diff --git a/src/components/FormGenerator/FormGeneratorTree/FormGeneratorTree.tsx b/src/components/FormGenerator/FormGeneratorTree/FormGeneratorTree.tsx index ef300db..2342e13 100644 --- a/src/components/FormGenerator/FormGeneratorTree/FormGeneratorTree.tsx +++ b/src/components/FormGenerator/FormGeneratorTree/FormGeneratorTree.tsx @@ -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 = { 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 = 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(nodes: TreeNode[]): Map { + 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 { isDragging: boolean; ownership: Ownership; compact: boolean; + selectable: boolean; + pendingActions: Set; provider: TreeNodeProvider; onToggleExpand: (id: string) => void; onToggleSelect: (id: string, e: React.MouseEvent) => void; @@ -138,6 +173,9 @@ interface TreeNodeRowProps { onSendToChat?: (node: TreeNode) => void; onCycleScope: (node: TreeNode) => void; onToggleNeutralize: (node: TreeNode) => void; + onToggleRagIndex: (node: TreeNode) => void; + onCreateChild?: (parentId: string) => void; + onExtraAction: (nodeId: string, action: NodeAction) => void; onDragStart: (e: React.DragEvent, node: TreeNode) => void; onDragOver: (e: React.DragEvent, node: TreeNode) => void; onDragLeave: (e: React.DragEvent) => void; @@ -154,6 +192,8 @@ const TreeNodeRow = React.memo(function TreeNodeRow({ isDragging, ownership, compact, + selectable, + pendingActions, provider, onToggleExpand, onToggleSelect, @@ -166,6 +206,9 @@ const TreeNodeRow = React.memo(function TreeNodeRow({ onSendToChat, onCycleScope, onToggleNeutralize, + onToggleRagIndex, + onCreateChild, + onExtraAction, onDragStart, onDragOver, onDragLeave, @@ -231,6 +274,12 @@ const TreeNodeRow = React.memo(function TreeNodeRow({ 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({ >
- {}} - onClick={(e) => { - e.stopPropagation(); - onToggleSelect(node.id, e as unknown as React.MouseEvent); - }} - tabIndex={-1} - /> + {selectable && ( + {}} + onClick={(e) => { + e.stopPropagation(); + onToggleSelect(node.id, e as unknown as React.MouseEvent); + }} + tabIndex={-1} + /> + )} {hasChildren ? ( ({
+ {canCreateChild && onCreateChild && ( + + )} + {canRename && ( )} - {node.type !== 'folder' && ( + {node.type !== 'folder' && provider.downloadNode && (
+ {/* Order (left-to-right): extraActions (e.g. settings) -> RAG -> sendToChat -> scope -> neutralize. */} + {node.extraActions?.map((action) => ( + + ))} + + {node.ragIndexEnabled !== undefined && ( + + )} + {onSendToChat && ( )} @@ -387,11 +496,18 @@ const TreeNodeRow = React.memo(function TreeNodeRow({ 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) + ? + : node.neutralize === 'mixed' + ? {_MIXED_SYMBOL} + : node.neutralize === true + ? _NEUTRALIZE_ON_EMOJI + : {_NEUTRALIZE_OFF_EMOJI}} )}
@@ -413,6 +529,8 @@ export function FormGeneratorTree({ onRefresh, onSendToChat, allowCreateFolder = true, + selectable = true, + refreshAfterAction = false, className, }: FormGeneratorTreeProps) { const { t } = useLanguage(); @@ -428,11 +546,103 @@ export function FormGeneratorTree({ const [dragOverId, setDragOverId] = useState(null); const [draggingIds, setDraggingIds] = useState>(new Set()); const [filterText, setFilterText] = useState(''); + /** Map of nodeId -> set of action keys currently pending (for spinner rendering). */ + const [pendingActions, setPendingActions] = useState>>(new Map()); const lastSelectedIdRef = useRef(null); const treeContentRef = useRef(null); + /** Tracks node ids for which auto-expand has already fired (one-shot). */ + const autoExpandedRef = useRef>(new Set()); + /** Stable ref to the current flatEntries so _refreshVisibleAttributes can + * read visible IDs without being in the dependency array. */ + const flatEntriesRef = useRef[]>([]); + + /** Deduplicating node append: merges `incoming` into `prev` by id. */ + const _mergeNodes = useCallback( + (prev: TreeNode[], incoming: TreeNode[]): TreeNode[] => { + 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 = {}; + 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) => { + 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({ _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[]), + ), + ); + 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({ return flatEntriesRaw.filter((e) => matchIds.has(e.node.id)); }, [flatEntriesRaw, filterText, nodes]); + flatEntriesRef.current = flatEntries; + const _updateSelection = useCallback( (newSelection: Set) => { setSelectedIds(newSelection); @@ -479,32 +730,39 @@ export function FormGeneratorTree({ 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(); + 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({ 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({ 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({ 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({ 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({ async (node: TreeNode) => { 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) => { - 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) => { + 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({ 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({
)} - {selectedIds.size > 0 && batchActions.length > 0 && ( + {selectable && selectedIds.size > 0 && batchActions.length > 0 && (
{selectedIds.size} selected {batchActions.map((action: TreeBatchAction) => { @@ -1009,6 +1297,8 @@ export function FormGeneratorTree({ 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({ onSendToChat={onSendToChat} onCycleScope={_handleCycleScope} onToggleNeutralize={_handleToggleNeutralize} + onToggleRagIndex={_handleToggleRagIndex} + onCreateChild={allowCreateFolder ? _createFolderAt : undefined} + onExtraAction={_handleExtraAction} onDragStart={_handleDragStart} onDragOver={_handleDragOver} onDragLeave={_handleDragLeave} diff --git a/src/components/FormGenerator/FormGeneratorTree/__tests__/FormGeneratorTree.test.tsx b/src/components/FormGenerator/FormGeneratorTree/__tests__/FormGeneratorTree.test.tsx index 11a8252..e1ad065 100644 --- a/src/components/FormGenerator/FormGeneratorTree/__tests__/FormGeneratorTree.test.tsx +++ b/src/components/FormGenerator/FormGeneratorTree/__tests__/FormGeneratorTree.test.tsx @@ -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) => { + 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 => 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(() => {})); // never resolves render(); 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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( + , + ); + + await waitFor(() => { + expect(screen.getByText('My Folder')).toBeInTheDocument(); + }); + + const initialLoadCalls = (provider.loadChildren as ReturnType).mock.calls.length; + + const neutralizeBtn = screen.getByTitle('Nicht neutralisiert'); + await user.click(neutralizeBtn); + + await waitFor(() => { + expect(provider.patchNeutralize).toHaveBeenCalled(); + }); + + // After action, at least one extra loadChildren(null, 'own') happened. + const newCalls = (provider.loadChildren as ReturnType).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(); + + await waitFor(() => { + expect(screen.getByText('My Folder')).toBeInTheDocument(); + }); + + const initialLoadCalls = (provider.loadChildren as ReturnType).mock.calls.length; + + const neutralizeBtn = screen.getByTitle('Nicht neutralisiert'); + await user.click(neutralizeBtn); + + await waitFor(() => { + expect(provider.patchNeutralize).toHaveBeenCalled(); + }); + + const newCalls = (provider.loadChildren as ReturnType).mock.calls.length; + expect(newCalls).toBe(initialLoadCalls); + }); + }); + + // --------------------------------------------------------------------------- + // selectable=false (UDB Sources mode) + // --------------------------------------------------------------------------- + describe('selectable=false', () => { + it('hides checkboxes when selectable=false', async () => { + const provider = _createMockProvider([_ownFolder]); + const { container } = render( + , + ); + + 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( + , + ); + + await waitFor(() => { + expect(screen.getByText('My Folder')).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('treeitem', { name: /My Folder/i })); + expect(screen.queryByText('Delete')).not.toBeInTheDocument(); + }); + }); }); diff --git a/src/components/FormGenerator/FormGeneratorTree/__tests__/dnd.test.tsx b/src/components/FormGenerator/FormGeneratorTree/__tests__/dnd.test.tsx index b15e01d..7cfd8f8 100644 --- a/src/components/FormGenerator/FormGeneratorTree/__tests__/dnd.test.tsx +++ b/src/components/FormGenerator/FormGeneratorTree/__tests__/dnd.test.tsx @@ -12,6 +12,27 @@ vi.mock('../../../../hooks/usePrompt', () => ({ PromptDialog: () => null, }), })); + +vi.mock('../../../../providers/language/LanguageContext', () => ({ + useLanguage: () => ({ + t: (key: string, vars?: Record) => { + 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 // --------------------------------------------------------------------------- diff --git a/src/components/FormGenerator/FormGeneratorTree/providers/FolderFileProvider.tsx b/src/components/FormGenerator/FormGeneratorTree/providers/FolderFileProvider.tsx index d120c6f..810b686 100644 --- a/src/components/FormGenerator/FormGeneratorTree/providers/FolderFileProvider.tsx +++ b/src/components/FormGenerator/FormGeneratorTree/providers/FolderFileProvider.tsx @@ -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: , + defaultExpanded: true, + scope: 'personal', + neutralize: false, + }; +} + export function createFolderFileProvider(): TreeNodeProvider { const ownerParam = (ownership: Ownership) => (ownership === 'own' ? 'me' : 'shared'); const typeMap = new Map(); @@ -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 { + const synthIds = ids.filter((id) => id.startsWith('__filesRoot:')); + if (synthIds.length === 0) return ids; + const out = new Set(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 = {}; - 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 = res.data ?? {}; + const result = new Map(); + for (const [id, attrs] of Object.entries(raw)) { + result.set(id, { + neutralize: attrs.neutralize, + scope: attrs.scope as ScopeValue | 'mixed', + }); + } + return result; + }, + getBatchActions(): TreeBatchAction[] { return [ { diff --git a/src/components/FormGenerator/FormGeneratorTree/types.ts b/src/components/FormGenerator/FormGeneratorTree/types.ts index d715033..563a4a3 100644 --- a/src/components/FormGenerator/FormGeneratorTree/types.ts +++ b/src/components/FormGenerator/FormGeneratorTree/types.ts @@ -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; +} + export interface TreeNode { 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[]; 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 { canMove?(source: TreeNode, target: TreeNode | null): boolean; canPatchScope?(node: TreeNode): boolean; canPatchNeutralize?(node: TreeNode): boolean; + canPatchRagIndex?(node: TreeNode): boolean; createChild?(parentId: string | null, name: string): Promise>; renameNode?(id: string, newName: string): Promise; deleteNodes?(ids: string[]): Promise; moveNodes?(ids: string[], targetParentId: string | null): Promise; patchScope?(ids: string[], scope: ScopeValue, cascadeChildren?: boolean): Promise; patchNeutralize?(ids: string[], neutralize: boolean): Promise; + patchRagIndex?(ids: string[], ragIndexEnabled: boolean): Promise; downloadNode?(node: TreeNode): Promise; getBatchActions?(): TreeBatchAction[]; + /** After a toggle action, the tree collects all currently visible node IDs + * and calls this method. The provider asks the backend for the current + * attribute values (incl. mixed) of exactly those IDs. The tree then + * patches only the attribute fields on existing nodes — no structural + * reload. If not implemented, the tree falls back to _refetchAllExpanded. */ + refreshAttributes?(ids: string[]): Promise>; + /** Called during drag-start to let the provider inject domain-specific MIME + * types into the DataTransfer (e.g. `application/datasource`). The generic + * tree always sets `application/tree-items` and `text/plain`; this hook + * adds provider-specific formats on top. */ + customizeDragData?(node: TreeNode, dataTransfer: DataTransfer): void; } export interface FormGeneratorTreeProps { @@ -62,5 +110,16 @@ export interface FormGeneratorTreeProps { onSendToChat?: (node: TreeNode) => 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; } diff --git a/src/components/UnifiedDataBar/FilesTab.tsx b/src/components/UnifiedDataBar/FilesTab.tsx index eb9f713..99697b7 100644 --- a/src/components/UnifiedDataBar/FilesTab.tsx +++ b/src/components/UnifiedDataBar/FilesTab.tsx @@ -34,6 +34,7 @@ const FilesTab: React.FC = ({ 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 = ({ context, onFileSelect, onSendToChat title={t('Eigene')} compact={true} showFilter={true} + refreshAfterAction onNodeClick={_handleNodeClickWithImport} onSendToChat={_handleSendToChat} /> @@ -211,6 +213,7 @@ const FilesTab: React.FC = ({ context, onFileSelect, onSendToChat compact={true} collapsible={true} defaultCollapsed={true} + refreshAfterAction emptyMessage={t('Keine geteilten Dateien')} onNodeClick={_handleNodeClickWithImport} onSendToChat={_handleSendToChat} diff --git a/src/components/UnifiedDataBar/SourcesTab.module.css b/src/components/UnifiedDataBar/SourcesTab.module.css deleted file mode 100644 index 793732c..0000000 --- a/src/components/UnifiedDataBar/SourcesTab.module.css +++ /dev/null @@ -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; -} diff --git a/src/components/UnifiedDataBar/SourcesTab.tsx b/src/components/UnifiedDataBar/SourcesTab.tsx index 4e0f131..d388f55 100644 --- a/src/components/UnifiedDataBar/SourcesTab.tsx +++ b/src/components/UnifiedDataBar/SourcesTab.tsx @@ -1,2539 +1,85 @@ +// Copyright (c) 2026 Patrick Motsch +// All rights reserved. /** - * SourcesTab – Full data-source management inside the Unified Data Bar. + * SourcesTab — UDB tab for personal connections + mandate data. * - * Tree structure (Browse Sources): - * UserConnection (Level 1, loaded on mount) - * └─ Service (Level 2, loaded when connection expanded) - * └─ Folder / Site / File (Level 3+, loaded when service/folder expanded) - * - * Feature Data tree (catalog-driven, supports recursive nesting): - * MandateGroup - * └─ FeatureConnection (feature instance) - * ├─ Group (categorical folder, isGroup=true) - * │ └─ ParentGroup or Table - * ├─ ParentGroup (table with isParent=true) → records - * │ └─ Record → child tables (which can themselves be ParentGroups → recursion) - * └─ Table (standalone) - * - * Path-aware state-keys (segments joined by '|', prefixed by featureInstanceId): - * g: - categorical group folder - * p: - parent group (record list of that table) - * r:: - specific record (its child tables rendered when expanded) - * - * Active Sources sections show scope-cycling and neutralize-toggle buttons. + * Architecture: + * - Backend is the single source of truth (`POST /api/workspace/{instanceId}/tree/children`). + * - Tree mechanism: generic `FormGeneratorTree` with a UDB-specific provider. + * - Inheritance, mixed-state aggregation and cascade-NULL on patch are + * ALL handled by the backend; the frontend never recomputes effective values. + * - Every flag toggle goes through `refreshAfterAction`: PATCH -> refetch all + * expanded parents -> atomic state replace. No optimistic updates. */ -import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import type { UdbContext } from './UnifiedDataBar'; -import api from '../../api'; -import { getPageIcon } from '../../config/pageRegistry'; -import styles from './SourcesTab.module.css'; +import { FormGeneratorTree } from '../FormGenerator/FormGeneratorTree'; +import { createUdbSourcesProvider } from './UdbSourcesProvider'; import { DataSourceSettingsModal } from './DataSourceSettingsModal'; -import { FaGoogle, FaMicrosoft, FaTasks, FaCloud, FaLink, FaFolder, FaEnvelope, FaComments, FaCalendarAlt, FaUser, FaCloudUploadAlt } from 'react-icons/fa'; -import { SiJira } from 'react-icons/si'; - import { useLanguage } from '../../providers/language/LanguageContext'; -/* ─── Types (inline, no external imports) ────────────────────────────── */ - -interface UdbDataSource { - id: string; - connectionId: string; - sourceType: string; - path: string; - label: string; - displayPath?: string; - /** Three-state cascade-inherit. null = inherit from nearest ancestor. */ - scope: string | null; - /** Three-state cascade-inherit. null = inherit. */ - neutralize: boolean | null; - /** Three-state cascade-inherit. null = inherit. */ - ragIndexEnabled: boolean | null; - settings?: Record | null; -} - -interface UdbFeatureDataSource { - id: string; - featureInstanceId: string; - featureCode: string; - tableName: string; - objectKey: string; - label: string; - /** Three-state cascade-inherit. null = inherit from nearest ancestor FDS. */ - scope: string | null; - /** Three-state cascade-inherit. null = inherit. */ - neutralize: boolean | null; - neutralizeFields?: string[]; - recordFilter?: Record; -} - -interface TreeNode { - key: string; - label: string; - icon: React.ReactNode; - type: 'connection' | 'service' | 'folder' | 'file'; - expanded: boolean; - loading: boolean; - children: TreeNode[] | null; - connectionId: string; - service?: string; - path?: string; - displayPath?: string; - authority?: string; - knowledgeIngestionEnabled?: boolean; -} - -interface FeatureConnectionNode { - featureInstanceId: string; - featureCode: string; - mandateId?: string; - label: string; - icon: string; - tableCount: number; - expanded: boolean; - loading: boolean; - tables: FeatureTableNode[] | null; -} - -interface MandateGroupNode { - mandateId: string; - mandateLabel: string; - expanded: boolean; - featureConnections: FeatureConnectionNode[]; -} - -interface FeatureTableNode { - objectKey: string; - tableName: string; - label: string; - fields: string[]; - isParent?: boolean; - parentTable?: string | null; - parentKey?: string | null; - displayFields?: string[]; - isGroup?: boolean; - group?: string | null; -} - -interface ParentRecordNode { - id: string; - displayLabel: string; - fields: Record; - tableName: string; -} - -/* ─── Props ──────────────────────────────────────────────────────────── */ - interface SourcesTabProps { context: UdbContext; onSourcesChanged?: () => void; - onSendToChat_FeatureSource?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string; fieldName?: string }) => void; + onSendToChat_FeatureSource?: (params: { + featureInstanceId: string; featureCode: string; tableName?: string; + objectKey: string; label: string; fieldName?: string; + }) => void; onAttachDataSource?: (dsId: string) => void; } -/* ─── Icons ──────────────────────────────────────────────────────────── */ - -const _AUTHORITY_ICONS: Record = { - msft: , - google: , - clickup: , - infomaniak: , - 'local:ftp': , - 'local:jira': , -}; - -const _SERVICE_ICONS: Record = { - sharepoint: , - onedrive: , - outlook: , - teams: , - drive: , - gmail: , - files: , - clickup: , - kdrive: , - mail: , - calendar: , - contact: , -}; - -/* ─── Source colors & icons ──────────────────────────────────────────── */ - -const _SOURCE_COLORS: Record = { - sharepointFolder: '#0078d4', - sharepoint: '#0078d4', - onedriveFolder: '#0078d4', - onedrive: '#0078d4', - outlookFolder: '#0078d4', - outlook: '#0078d4', - googleDriveFolder: '#34a853', - drive: '#34a853', - gmailFolder: '#ea4335', - gmail: '#ea4335', - ftpFolder: '#795548', - files: '#795548', - 'local:ftp': '#795548', - 'local:jira': '#0052CC', - clickup: '#7b68ee', - kdriveFolder: '#0098FF', - kdrive: '#0098FF', - mailFolder: '#0098FF', - mail: '#0098FF', - calendarFolder: '#0098FF', - calendar: '#0098FF', - contactFolder: '#0098FF', - contact: '#0098FF', -}; - -function _getSourceColor(sourceType: string): string { - return _SOURCE_COLORS[sourceType] || '#F25843'; -} - -/* ─── Scope / Neutralize constants ───────────────────────────────────── */ - -const _SCOPE_ORDER: string[] = ['personal', 'featureInstance', 'mandate']; - -const _SCOPE_ICONS: Record = { - personal: '\uD83D\uDC64', - featureInstance: '\uD83D\uDC65', - mandate: '\uD83C\uDFE2', - global: '\uD83C\uDF10', -}; - -function _nextScope(current: string): string { - const idx = _SCOPE_ORDER.indexOf(current); - if (idx === -1) return _SCOPE_ORDER[0]; - return _SCOPE_ORDER[(idx + 1) % _SCOPE_ORDER.length]; -} - -const _SERVICE_TO_SOURCE_TYPE: Record = { - sharepoint: 'sharepointFolder', - onedrive: 'onedriveFolder', - outlook: 'outlookFolder', - drive: 'googleDriveFolder', - gmail: 'gmailFolder', - files: 'ftpFolder', - clickup: 'clickup', - kdrive: 'kdriveFolder', - mail: 'mailFolder', - calendar: 'calendarFolder', - contact: 'contactFolder', -}; - -/* ─── Tree helpers ───────────────────────────────────────────────────── */ - -function _mapTree(nodes: TreeNode[], key: string, updater: (n: TreeNode) => TreeNode): TreeNode[] { - return nodes.map(n => { - if (n.key === key) return updater(n); - if (n.children) return { ...n, children: _mapTree(n.children, key, updater) }; - return n; - }); -} - -function _mapFeatureTreeUpdate( - prev: MandateGroupNode[], - featureInstanceId: string, - updater: (n: FeatureConnectionNode) => FeatureConnectionNode, -): MandateGroupNode[] { - return prev.map(g => ({ - ...g, - featureConnections: g.featureConnections.map(n => - n.featureInstanceId === featureInstanceId ? updater(n) : n - ), - })); -} - -function _findTableFields( - groups: MandateGroupNode[], - featureInstanceId: string, - tableName: string, -): string[] { - for (const g of groups) { - const fc = g.featureConnections.find(f => f.featureInstanceId === featureInstanceId); - if (fc?.tables) { - const tbl = fc.tables.find(t => t.tableName === tableName); - if (tbl) return tbl.fields; - } - } - return []; -} - -/* ─── Feature tree builder (catalog → renderable items) ──────────────── */ - -type FeatureItem = - | { kind: 'group'; objectKey: string; label: string; items: FeatureItem[] } - | { kind: 'parentGroup'; table: FeatureTableNode } - | { kind: 'table'; table: FeatureTableNode }; - -/** - * Build the top-level feature tree from the flat catalog table list. - * - * - Items with `isGroup: true` become categorical group folders. - * - Items with `group: ` are placed inside the corresponding group. - * - Items with `parentTable` set are NOT rendered at top level — they are - * rendered dynamically when a parent record is expanded. - * - Items with `isParent: true` (and no parentTable) become top-level parent groups. - * - Everything else renders as a standalone table. - * - * Catalog declaration order is preserved; group children appear nested under - * the group folder in the order they were declared. - */ -function _buildTopFeatureTree(tables: FeatureTableNode[]): FeatureItem[] { - const groupChildren: Record = {}; - for (const t of tables) { - if (t.isGroup) groupChildren[t.objectKey] = []; - } - - const result: FeatureItem[] = []; - for (const t of tables) { - if (t.isGroup) { - result.push({ kind: 'group', objectKey: t.objectKey, label: t.label, items: groupChildren[t.objectKey] }); - } else if (t.parentTable) { - // Skip — child tables are rendered when their parent record is expanded. - continue; - } else if (t.group && groupChildren[t.group]) { - const item: FeatureItem = t.isParent - ? { kind: 'parentGroup', table: t } - : { kind: 'table', table: t }; - groupChildren[t.group].push(item); - } else if (t.isParent) { - result.push({ kind: 'parentGroup', table: t }); - } else { - result.push({ kind: 'table', table: t }); - } - } - return result; -} - -/** - * Children of a parent record: child tables (where parentTable === recordTableName) - * rendered as parentGroup or standalone table (recursion enables N-level nesting). - */ -function _childrenForRecord(allTables: FeatureTableNode[], parentTableName: string): FeatureItem[] { - return allTables - .filter(t => t.parentTable === parentTableName) - .map(t => t.isParent - ? { kind: 'parentGroup', table: t } - : { kind: 'table', table: t }); -} - -function _pathKey(featureInstanceId: string, segments: string[]): string { - return [featureInstanceId, ...segments].join('|'); -} - -/** Walks back through a path to find the closest preceding `r::` segment. */ -function _closestRecordSegment(segments: string[]): { tableName: string; recordId: string } | null { - for (let i = segments.length - 1; i >= 0; i--) { - const seg = segments[i]; - if (seg.startsWith('r:')) { - const rest = seg.slice(2); - const sepIdx = rest.indexOf(':'); - if (sepIdx > 0) { - return { tableName: rest.slice(0, sepIdx), recordId: rest.slice(sepIdx + 1) }; - } - } - } - return null; -} - -/* ─── Data fetching (module-level) ───────────────────────────────────── */ - -async function _loadServices(instanceId: string, connectionId: string): Promise { - const res = await api.get(`/api/workspace/${instanceId}/connections/${connectionId}/services`); - const services = res.data.services || []; - return services.map((s: any) => ({ - key: `svc-${connectionId}-${s.service}`, - label: s.label || s.service, - icon: _SERVICE_ICONS[s.service] || , - type: 'service' as const, - expanded: false, - loading: false, - children: null, - connectionId, - service: s.service, - path: '/', - displayPath: s.label || s.service, - })).sort((a: TreeNode, b: TreeNode) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })); -} - -async function _browseService( - instanceId: string, - connectionId: string, - service: string, - path: string, - parentDisplayPath: string | undefined, -): Promise { - const res = await api.get(`/api/workspace/${instanceId}/connections/${connectionId}/browse`, { - params: { service, path }, - }); - const items = res.data.items || []; - return items.map((entry: any, idx: number) => { - const seg = entry.name || ''; - const displayPath = parentDisplayPath - ? `${parentDisplayPath} / ${seg}` - : seg; - return { - key: `item-${connectionId}-${service}-${entry.path || idx}`, - label: entry.name, - icon: entry.isFolder ? '\uD83D\uDCC1' : _fileIcon(entry.name), - type: entry.isFolder ? 'folder' as const : 'file' as const, - expanded: false, - loading: false, - children: entry.isFolder ? null : [], - connectionId, - service, - path: entry.path, - displayPath, - }; - }).sort((a: TreeNode, b: TreeNode) => { - if (a.type === 'folder' && b.type !== 'folder') return -1; - if (a.type !== 'folder' && b.type === 'folder') return 1; - return a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }); - }); -} - -function _fileIcon(name: string): string { - const ext = name.split('.').pop()?.toLowerCase() || ''; - const map: Record = { - pdf: '\uD83D\uDCC4', doc: '\uD83D\uDCDD', docx: '\uD83D\uDCDD', - xls: '\uD83D\uDCCA', xlsx: '\uD83D\uDCCA', csv: '\uD83D\uDCCA', - ppt: '\uD83D\uDCC8', pptx: '\uD83D\uDCC8', - txt: '\uD83D\uDCC4', json: '\uD83D\uDCCB', - png: '\uD83D\uDDBC\uFE0F', jpg: '\uD83D\uDDBC\uFE0F', jpeg: '\uD83D\uDDBC\uFE0F', - zip: '\uD83D\uDCE6', rar: '\uD83D\uDCE6', - mp3: '\uD83C\uDFB5', wav: '\uD83C\uDFB5', - mp4: '\uD83C\uDFAC', mov: '\uD83C\uDFAC', - }; - return map[ext] || '\uD83D\uDCC4'; -} - -/* ─── Spinner (inline) ───────────────────────────────────────────────── */ - -function _Spinner(): React.ReactElement { - return ( - - ); -} - -/* ─── Component ──────────────────────────────────────────────────────── */ - -const SourcesTab: React.FC = ({ context, onSourcesChanged, onSendToChat_FeatureSource, onAttachDataSource }) => { +const SourcesTab: React.FC = ({ context }) => { const { t } = useLanguage(); - const _scopeLabel = (scope: string) => ({ - personal: t('Persönlich'), - featureInstance: t('Feature-Instanz'), - mandate: t('Mandant'), - global: t('Global'), - } as Record)[scope] || scope; - const _scopeCycleTitle = (scope: string) => - `${t('Bereich')}: ${_scopeLabel(scope)} → ${_scopeLabel(_nextScope(scope))}`; const instanceId = context.instanceId; - /* ── Active sources (fetched internally) ── */ - const [dataSources, setDataSources] = useState([]); - const [featureDataSources, setFeatureDataSources] = useState([]); - - /* ── Browse tree state ── */ - const [tree, setTree] = useState([]); - const [loadingRoot, setLoadingRoot] = useState(false); - const [addingPath, setAddingPath] = useState(null); - - /* ── Feature tree state ── */ - const [featureTree, setFeatureTree] = useState([]); - const [loadingFeatures, setLoadingFeatures] = useState(false); - const [addingFeatureKey, setAddingFeatureKey] = useState(null); - - /* ── Path-aware feature node state (groups, parent groups, records) ── */ - const [featureExpandedPaths, setFeatureExpandedPaths] = useState>(new Set()); - const [featureRecordsByPath, setFeatureRecordsByPath] = useState>({}); - const [featureLoadingPath, setFeatureLoadingPath] = useState(null); - const [addingRecordPath, setAddingRecordPath] = useState(null); - - /* ── Multi-selection state for Browse-Tree ── */ - const [selectedKeys, setSelectedKeys] = useState>(new Set()); - const lastClickedKeyRef = useRef(null); - - /* ── DataSource Settings modal ── */ const [settingsModal, setSettingsModal] = useState<{ - dataSourceId?: string; - connectionId?: string; + dataSourceId: string; title: string; - initialKnowledgeIngestionEnabled?: boolean; - initialRagLimits?: any; - showRagSection?: boolean; } | null>(null); - const _flattenVisibleKeys = useCallback((nodes: TreeNode[]): string[] => { - const result: string[] = []; - for (const n of nodes) { - result.push(n.key); - if (n.expanded && n.children) { - result.push(..._flattenVisibleKeys(n.children)); - } - } - return result; + const _handleOpenSettings = useCallback((dataSourceId: string, label: string) => { + setSettingsModal({ dataSourceId, title: label }); }, []); - const _handleNodeSelect = useCallback((node: TreeNode, e: React.MouseEvent) => { - if (e.ctrlKey || e.metaKey) { - setSelectedKeys(prev => { - const next = new Set(prev); - if (next.has(node.key)) next.delete(node.key); else next.add(node.key); - return next; - }); - lastClickedKeyRef.current = node.key; - } else if (e.shiftKey && lastClickedKeyRef.current) { - const visible = _flattenVisibleKeys(tree); - const a = visible.indexOf(lastClickedKeyRef.current); - const b = visible.indexOf(node.key); - if (a !== -1 && b !== -1) { - const [start, end] = a < b ? [a, b] : [b, a]; - setSelectedKeys(new Set(visible.slice(start, end + 1))); - } - } else { - setSelectedKeys(new Set([node.key])); - lastClickedKeyRef.current = node.key; - } - }, [tree, _flattenVisibleKeys]); + const provider = useMemo( + () => instanceId + ? createUdbSourcesProvider(instanceId, _handleOpenSettings) + : null, + [instanceId, _handleOpenSettings], + ); - const mountedRef = useRef(true); - useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); - - /* ── Fetch active personal data sources ── */ - const _fetchDataSources = useCallback(() => { - if (!instanceId) return; - api.get(`/api/workspace/${instanceId}/datasources`) - .then(res => { - if (!mountedRef.current) return; - const list: UdbDataSource[] = (res.data.dataSources || res.data || []).map((d: any) => ({ - id: d.id, - connectionId: d.connectionId, - sourceType: d.sourceType, - path: d.path, - label: d.label, - displayPath: d.displayPath, - scope: d.scope ?? null, - neutralize: d.neutralize ?? null, - ragIndexEnabled: d.ragIndexEnabled ?? null, - settings: d.settings ?? null, - })); - list.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })); - setDataSources(list); - }) - .catch(() => { if (mountedRef.current) setDataSources([]); }); - }, [instanceId]); - - /* ── Fetch active feature data sources ── */ - const _fetchFeatureDataSources = useCallback(() => { - if (!instanceId) return; - api.get(`/api/workspace/${instanceId}/feature-datasources`) - .then(res => { - if (!mountedRef.current) return; - const list: UdbFeatureDataSource[] = (res.data.featureDataSources || res.data || []).map((d: any) => ({ - id: d.id, - featureInstanceId: d.featureInstanceId, - featureCode: d.featureCode, - tableName: d.tableName, - objectKey: d.objectKey, - label: d.label, - scope: d.scope ?? null, - neutralize: d.neutralize ?? null, - neutralizeFields: d.neutralizeFields || undefined, - recordFilter: d.recordFilter || undefined, - })); - list.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })); - setFeatureDataSources(list); - }) - .catch(() => { if (mountedRef.current) setFeatureDataSources([]); }); - }, [instanceId]); - - useEffect(() => { _fetchDataSources(); }, [_fetchDataSources]); - useEffect(() => { _fetchFeatureDataSources(); }, [_fetchFeatureDataSources]); - - /* ── Load Level 1: UserConnections ── */ - const _loadConnections = useCallback(() => { - if (!instanceId) return; - setLoadingRoot(true); - api.get(`/api/workspace/${instanceId}/connections`) - .then(res => { - if (!mountedRef.current) return; - const conns = res.data.connections || []; - const nodes: TreeNode[] = conns - .filter((c: any) => c.status === 'active') - .map((c: any) => ({ - key: `conn-${c.id}`, - label: c.externalEmail || c.externalUsername || c.authority, - icon: _AUTHORITY_ICONS[c.authority] || , - type: 'connection' as const, - expanded: false, - loading: false, - children: null, - connectionId: c.id, - authority: c.authority, - knowledgeIngestionEnabled: !!c.knowledgeIngestionEnabled, - })) - .sort((a: TreeNode, b: TreeNode) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })); - setTree(nodes); - }) - .catch(() => { if (mountedRef.current) setTree([]); }) - .finally(() => { if (mountedRef.current) setLoadingRoot(false); }); - }, [instanceId]); - - useEffect(() => { _loadConnections(); }, [_loadConnections]); - - /* ── Generic tree update helper ── */ - const _updateNode = useCallback((key: string, updater: (node: TreeNode) => TreeNode) => { - setTree(prev => _mapTree(prev, key, updater)); - }, []); - - /* ── Toggle expand/collapse ── */ - const _toggleNode = useCallback(async (node: TreeNode) => { - if (node.expanded) { - _updateNode(node.key, n => ({ ...n, expanded: false })); - return; - } - - if (node.children !== null) { - _updateNode(node.key, n => ({ ...n, expanded: true })); - return; - } - - _updateNode(node.key, n => ({ ...n, loading: true, expanded: true })); - - try { - let children: TreeNode[] = []; - - if (node.type === 'connection') { - children = await _loadServices(instanceId, node.connectionId); - } else if (node.type === 'service' || node.type === 'folder') { - children = await _browseService( - instanceId, - node.connectionId, - node.service!, - node.path || '/', - node.displayPath || node.label, - ); - } - - if (mountedRef.current) { - _updateNode(node.key, n => ({ ...n, loading: false, children })); - } - } catch { - if (mountedRef.current) { - _updateNode(node.key, n => ({ ...n, loading: false, children: [] })); - } - } - }, [instanceId, _updateNode]); - - /* ── Add as DataSource ── */ - const _addAsDataSource = useCallback(async (node: TreeNode): Promise => { - if (!node.connectionId) return null; - const sourceType = node.service - ? (_SERVICE_TO_SOURCE_TYPE[node.service] || node.service) - : (node.authority || node.type); - setAddingPath(node.key); - try { - const res = await api.post(`/api/workspace/${instanceId}/datasources`, { - connectionId: node.connectionId, - sourceType, - path: node.path || '/', - label: node.label, - displayPath: node.displayPath || node.label, - }); - _fetchDataSources(); - onSourcesChanged?.(); - return res.data?.id || res.data?.dataSource?.id || null; - } catch (err) { - console.error('Failed to add data source:', err); - return null; - } finally { - if (mountedRef.current) setAddingPath(null); - } - }, [instanceId, _fetchDataSources, onSourcesChanged]); - - /* ── Check if a path is already added ── */ - const _isAdded = useCallback((connectionId: string, service: string | undefined, path: string | undefined): boolean => { - const expectedSourceType = service ? (_SERVICE_TO_SOURCE_TYPE[service] || service) : undefined; - return dataSources.some(ds => - ds.connectionId === connectionId && - ds.path === (path || '/') && - (!expectedSourceType || ds.sourceType === expectedSourceType), + if (!instanceId || !provider) { + return ( +
+ {t('Keine Workspace-Instanz aktiv.')} +
); - }, [dataSources]); - - /* ── Send node to chat: ensure DataSource exists, then attach ── */ - const _sendNodeToChat = useCallback(async (params: { connectionId: string; sourceType: string; path: string; label: string; displayPath?: string }) => { - if (!onAttachDataSource) return; - const expectedSourceType = params.sourceType; - let ds = dataSources.find(d => - d.connectionId === params.connectionId && - d.path === (params.path || '/') && - d.sourceType === expectedSourceType, - ); - if (ds) { - onAttachDataSource(ds.id); - return; - } - try { - const res = await api.post(`/api/workspace/${instanceId}/datasources`, { - connectionId: params.connectionId, - sourceType: params.sourceType, - path: params.path || '/', - label: params.label, - displayPath: params.displayPath || params.label, - }); - const newId = res.data?.id || res.data?.dataSource?.id; - if (newId) { - onAttachDataSource(newId); - _fetchDataSources(); - onSourcesChanged?.(); - } - } catch (err) { - console.error('Failed to send data source to chat:', err); - } - }, [instanceId, dataSources, onAttachDataSource, _fetchDataSources, onSourcesChanged]); - - /* ── Node-based toggles (auto-create DS if missing) ──────────────────── - * Logik: Klick auf jedem Knoten togglt nur diesen Knoten. Children erben - * visuell. Wenn der Knoten noch keinen DataSource-Record hat, wird einer - * angelegt UND mit dem neuen Wert befüllt — atomar im UI-State, damit - * keine Race-Condition zwischen POST/PATCH und UI-Refetch entsteht. */ - /** - * Toggle on a node: - * - Compute newValue = !currentEffective (inverse of what user sees right now). - * - PATCH the explicit value; backend cascades and resets explicit descendants - * of this node back to NULL (= inherit). - * - Local state: refetch after PATCH so cascade-reset descendants update. - */ - const _toggleNeutralizeOnNode = useCallback(async (node: TreeNode, currentEffective: boolean) => { - const newValue = !currentEffective; - let ds = _findDs(dataSources, node); - let dsId = ds?.id; - if (!dsId) { - const newId = await _addAsDataSource(node); - if (!newId) return; - dsId = newId; - } - setDataSources(prev => prev.map(d => d.id === dsId ? { ...d, neutralize: newValue } : d)); - try { - await api.patch(`/api/datasources/${dsId}/neutralize`, { neutralize: newValue }); - _fetchDataSources(); - } catch { - _fetchDataSources(); - } - }, [dataSources, _addAsDataSource, _fetchDataSources]); - - const _toggleRagIndexOnNode = useCallback(async (node: TreeNode, currentEffective: boolean) => { - const newValue = !currentEffective; - let ds = _findDs(dataSources, node); - let dsId = ds?.id; - if (!dsId) { - const newId = await _addAsDataSource(node); - if (!newId) return; - dsId = newId; - } - setDataSources(prev => prev.map(d => d.id === dsId ? { ...d, ragIndexEnabled: newValue } : d)); - try { - await api.patch(`/api/datasources/${dsId}/rag-index`, { ragIndexEnabled: newValue }); - _fetchDataSources(); - } catch { - _fetchDataSources(); - } - }, [dataSources, _addAsDataSource, _fetchDataSources]); - - const _cycleScopeOnNode = useCallback(async (node: TreeNode, currentEffective: string | undefined) => { - const newScope = _nextScope(currentEffective || 'personal'); - let ds = _findDs(dataSources, node); - let dsId = ds?.id; - if (!dsId) { - const newId = await _addAsDataSource(node); - if (!newId) return; - dsId = newId; - } - setDataSources(prev => prev.map(d => d.id === dsId ? { ...d, scope: newScope } : d)); - try { - await api.patch(`/api/datasources/${dsId}/scope`, { scope: newScope }); - _fetchDataSources(); - } catch { - _fetchDataSources(); - } - }, [dataSources, _addAsDataSource, _fetchDataSources]); - - const _openSettingsForNode = useCallback(async (node: TreeNode) => { - const ds = _findDs(dataSources, node); - let dataSourceId = ds?.id; - if (!dataSourceId && node.type !== 'connection') { - const ensured = await _addAsDataSource(node); - if (ensured) dataSourceId = ensured; - } - const connNode = tree.find(n => n.connectionId === node.connectionId); - // RAG-Limits only on DataSource-Root (Level 2 = service node). - // Sub-elements (folder/file) inherit their parent's walker limits. - const isDataSourceRoot = node.type === 'service'; - setSettingsModal({ - dataSourceId, - connectionId: node.connectionId, - title: node.label || node.connectionId || t('Einstellungen'), - initialKnowledgeIngestionEnabled: connNode?.knowledgeIngestionEnabled ?? false, - initialRagLimits: (ds?.settings?.ragLimits ?? null) as any, - showRagSection: isDataSourceRoot, - }); - }, [dataSources, tree, _addAsDataSource, t]); - - /* ── Scope change (feature data source, optimistic) ── */ - const _cycleFeatureScope = useCallback(async (fds: UdbFeatureDataSource, currentEffective?: string) => { - const baseline = currentEffective || fds.scope || 'personal'; - const newScope = _nextScope(baseline); - setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, scope: newScope } : d)); - try { - await api.patch(`/api/datasources/${fds.id}/scope`, { scope: newScope }); - _fetchFeatureDataSources(); - } catch { - _fetchFeatureDataSources(); - } - }, [_fetchFeatureDataSources]); - - /* ── Neutralize toggle (feature data source, optimistic) ── */ - const _toggleFeatureNeutralize = useCallback(async (fds: UdbFeatureDataSource, currentEffective?: boolean) => { - const baseline = currentEffective !== undefined ? currentEffective : !!fds.neutralize; - const newValue = !baseline; - setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, neutralize: newValue } : d)); - try { - await api.patch(`/api/datasources/${fds.id}/neutralize`, { neutralize: newValue }); - _fetchFeatureDataSources(); - } catch { - _fetchFeatureDataSources(); - } - }, [_fetchFeatureDataSources]); - - /* ── Neutralize fields toggle (field-level, optimistic) ── */ - const _toggleNeutralizeField = useCallback(async (fds: UdbFeatureDataSource, fieldName: string) => { - const current = fds.neutralizeFields || []; - const updated = current.includes(fieldName) - ? current.filter(f => f !== fieldName) - : [...current, fieldName]; - const newFields = updated.length > 0 ? updated : undefined; - setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, neutralizeFields: newFields } : d)); - try { - await api.patch(`/api/datasources/${fds.id}/neutralize-fields`, { neutralizeFields: newFields || [] }); - } catch { - setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, neutralizeFields: fds.neutralizeFields } : d)); - } - }, []); - - /* ── Feature Connections: Load Level 1 ── */ - const _loadFeatureConnections = useCallback(() => { - if (!instanceId) return; - setLoadingFeatures(true); - api.get(`/api/workspace/${instanceId}/feature-connections`) - .then(res => { - if (!mountedRef.current) return; - const groups = res.data.featureConnectionsByMandate || []; - setFeatureTree(groups.map((g: any) => ({ - mandateId: g.mandateId, - mandateLabel: g.mandateLabel || g.mandateId, - expanded: true, - featureConnections: (g.featureConnections || []).map((c: any) => ({ - featureInstanceId: c.featureInstanceId, - featureCode: c.featureCode, - mandateId: c.mandateId, - label: c.label, - icon: c.icon || '\uD83D\uDDC3\uFE0F', - tableCount: c.tableCount || 0, - expanded: false, - loading: false, - tables: null, - })).sort((a: FeatureConnectionNode, b: FeatureConnectionNode) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })), - }))); - }) - .catch(() => { if (mountedRef.current) setFeatureTree([]); }) - .finally(() => { if (mountedRef.current) setLoadingFeatures(false); }); - }, [instanceId]); - - useEffect(() => { _loadFeatureConnections(); }, [_loadFeatureConnections]); - - /* ── Feature Connections: Toggle mandate group ── */ - const _toggleMandateGroup = useCallback((mandateId: string) => { - setFeatureTree(prev => prev.map(g => - g.mandateId === mandateId ? { ...g, expanded: !g.expanded } : g - )); - }, []); - - /* ── Feature Connections: Toggle expand ── */ - const _toggleFeatureNode = useCallback(async (node: FeatureConnectionNode) => { - if (node.expanded) { - setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ ...n, expanded: false }))); - return; - } - - if (node.tables !== null) { - setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ ...n, expanded: true }))); - return; - } - - setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ - ...n, loading: true, expanded: true, - }))); - - try { - const res = await api.get(`/api/workspace/${instanceId}/feature-connections/${node.featureInstanceId}/tables`); - const tables: FeatureTableNode[] = (res.data.tables || []).map((t: any) => ({ - objectKey: t.objectKey ?? '', - tableName: t.tableName ?? '', - label: t.label ?? '', - fields: (t.fields ?? []).slice().sort((a: string, b: string) => a.localeCompare(b, undefined, { sensitivity: 'base' })), - isParent: Boolean(t.isParent), - parentTable: t.parentTable ?? null, - parentKey: t.parentKey ?? null, - displayFields: t.displayFields ?? [], - isGroup: Boolean(t.isGroup), - group: t.group ?? null, - })).sort((a: FeatureTableNode, b: FeatureTableNode) => (a.label || a.tableName).localeCompare(b.label || b.tableName, undefined, { sensitivity: 'base' })); - - // Default-expand all categorical groups so users immediately see their content. - const defaultExpansions: string[] = tables - .filter(t => t.isGroup) - .map(t => _pathKey(node.featureInstanceId, [`g:${t.objectKey}`])); - if (defaultExpansions.length > 0) { - setFeatureExpandedPaths(prev => { - const next = new Set(prev); - for (const k of defaultExpansions) next.add(k); - return next; - }); - } - - if (mountedRef.current) { - setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ - ...n, loading: false, tables, - }))); - } - } catch { - if (mountedRef.current) { - setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ - ...n, loading: false, tables: [], - }))); - } - } - }, [instanceId]); - - /* ── Feature: Add table as FeatureDataSource ── */ - const _addFeatureTable = useCallback(async ( - node: FeatureConnectionNode, - table: FeatureTableNode, - extra?: { recordFilter?: Record; labelOverride?: string }, - ): Promise => { - const key = `${node.featureInstanceId}-${table.tableName}`; - setAddingFeatureKey(key); - try { - const res = await api.post(`/api/workspace/${instanceId}/feature-datasources`, { - featureInstanceId: node.featureInstanceId, - featureCode: node.featureCode, - tableName: table.tableName, - objectKey: table.objectKey, - label: extra?.labelOverride || table.label || table.tableName, - ...(extra?.recordFilter ? { recordFilter: extra.recordFilter } : {}), - }); - _fetchFeatureDataSources(); - onSourcesChanged?.(); - return res.data?.id || null; - } catch (err) { - console.error('Failed to add feature data source:', err); - return null; - } finally { - if (mountedRef.current) setAddingFeatureKey(null); - } - }, [instanceId, _fetchFeatureDataSources, onSourcesChanged]); - - /* ── Feature: check if table already added (no record filter) ── */ - const _isFeatureTableAdded = useCallback((featureInstanceId: string, tableName: string): boolean => { - return featureDataSources.some(fds => - fds.featureInstanceId === featureInstanceId - && fds.tableName === tableName - && !fds.recordFilter, - ); - }, [featureDataSources]); - - /* ── Feature: toggle expand for a path-keyed node (group / parent group / record) ── */ - const _toggleFeaturePath = useCallback((pathKey: string) => { - setFeatureExpandedPaths(prev => { - const next = new Set(prev); - if (next.has(pathKey)) next.delete(pathKey); else next.add(pathKey); - return next; - }); - }, []); - - /** - * Load records for a parent group at a given path. - * If the path contains a preceding `r:
:` segment, the records are - * filtered to children of that ancestor record (nested record hierarchy). - */ - const _loadRecordsAtPath = useCallback(async ( - node: FeatureConnectionNode, - table: FeatureTableNode, - parentPathSegments: string[], - ) => { - const segments = [...parentPathSegments, `p:${table.tableName}`]; - const pathKey = _pathKey(node.featureInstanceId, segments); - - if (featureRecordsByPath[pathKey]) return; - - setFeatureLoadingPath(pathKey); - try { - const params: Record = {}; - const ancestor = _closestRecordSegment(parentPathSegments); - if (ancestor && table.parentKey) { - params.parentKey = table.parentKey; - params.parentValue = ancestor.recordId; - } - - const res = await api.get( - `/api/workspace/${instanceId}/feature-connections/${node.featureInstanceId}/parent-objects/${table.tableName}`, - Object.keys(params).length > 0 ? { params } : undefined, - ); - const records: ParentRecordNode[] = (res.data.parentObjects || []).map((r: any) => ({ - id: r.id, - displayLabel: r.displayLabel || r.id, - fields: r.fields || {}, - tableName: table.tableName, - })).sort((a: ParentRecordNode, b: ParentRecordNode) => a.displayLabel.localeCompare(b.displayLabel, undefined, { sensitivity: 'base' })); - if (mountedRef.current) { - setFeatureRecordsByPath(prev => ({ ...prev, [pathKey]: records })); - } - } catch { - if (mountedRef.current) { - setFeatureRecordsByPath(prev => ({ ...prev, [pathKey]: [] })); - } - } finally { - if (mountedRef.current) setFeatureLoadingPath(null); - } - }, [instanceId, featureRecordsByPath]); - - /** - * Add a parent record + all its DIRECT child tables as FeatureDataSources. - * - * - Parent itself: recordFilter = { id: } - * - Each direct child table: recordFilter = { : } - * - * Nested parent groups (e.g. CoachingSession under CoachingContext) are added - * with the FK-only filter, scoping to "all sub-records of this ancestor". - * Users can drill in further to add a specific sub-record. - */ - const _addRecordWithChildren = useCallback(async ( - node: FeatureConnectionNode, - parentTable: FeatureTableNode, - record: ParentRecordNode, - pathSegments: string[], - ) => { - const addKey = `${_pathKey(node.featureInstanceId, pathSegments)}|r:${parentTable.tableName}:${record.id}`; - setAddingRecordPath(addKey); - try { - const allTables = node.tables || []; - const parentLabel = `${parentTable.label || parentTable.tableName}: ${record.displayLabel}`; - await api.post(`/api/workspace/${instanceId}/feature-datasources`, { - featureInstanceId: node.featureInstanceId, - featureCode: node.featureCode, - tableName: parentTable.tableName, - objectKey: parentTable.objectKey, - label: parentLabel, - recordFilter: { id: record.id }, - }); - - const childTables = allTables.filter(t => t.parentTable === parentTable.tableName); - for (const child of childTables) { - if (!child.parentKey) continue; - const childLabel = `${child.label || child.tableName}: ${record.displayLabel}`; - await api.post(`/api/workspace/${instanceId}/feature-datasources`, { - featureInstanceId: node.featureInstanceId, - featureCode: node.featureCode, - tableName: child.tableName, - objectKey: child.objectKey, - label: childLabel, - recordFilter: { [child.parentKey]: record.id }, - }); - } - - _fetchFeatureDataSources(); - onSourcesChanged?.(); - } catch (err) { - console.error('Failed to add record sources:', err); - } finally { - if (mountedRef.current) setAddingRecordPath(null); - } - }, [instanceId, _fetchFeatureDataSources, onSourcesChanged]); - - /* ── Check if a parent record is already added ── */ - const _isRecordAdded = useCallback((featureInstanceId: string, parentTableName: string, recordId: string): boolean => { - return featureDataSources.some(fds => - fds.featureInstanceId === featureInstanceId - && fds.tableName === parentTableName - && fds.recordFilter?.id === recordId, - ); - }, [featureDataSources]); - - /* ── Render ── */ + } return ( -
- {/* ── Browse Sources header ── */} -
- - {t('Quellen durchsuchen')} - - -
- - {/* ── Browse Sources tree ── */} - {loadingRoot && tree.length === 0 && ( -
- {t('Verbindungen werden geladen…')} -
- )} - - {!loadingRoot && tree.length === 0 && ( -
- {t('Keine aktiven Verbindungen.')} -
- )} - - {tree.map(node => ( - <_TreeNodeView - key={node.key} - node={node} - depth={0} - onToggle={_toggleNode} - isAdded={_isAdded} - addingPath={addingPath} - dataSources={dataSources} - onCycleScopeOnNode={_cycleScopeOnNode} - onToggleNeutralizeOnNode={_toggleNeutralizeOnNode} - onToggleRagIndexOnNode={_toggleRagIndexOnNode} - onOpenSettings={_openSettingsForNode} - onSendToChat={_sendNodeToChat} - scopeCycleTitle={_scopeCycleTitle} - selectedKeys={selectedKeys} - onSelect={_handleNodeSelect} +
+
+ - ))} - - {/* ── Divider ── */} -
- - {/* ── Feature Data header ── */} -
- - {t('Feature-Daten')} - -
- - {/* ── Feature Data tree ── */} - {loadingFeatures && featureTree.length === 0 && ( -
- {t('Feature-Instanzen werden geladen…')} -
- )} - - {!loadingFeatures && featureTree.length === 0 && ( -
- {t('Keine Feature-Instanzen gefunden.')} -
- )} - - {featureTree.map(g => ( - <_MandateGroupView - key={g.mandateId} - group={g} - onToggleGroup={_toggleMandateGroup} - onToggleFeature={_toggleFeatureNode} - onAddFeatureTable={_addFeatureTable} - isTableAdded={_isFeatureTableAdded} - addingKey={addingFeatureKey} - featureExpandedPaths={featureExpandedPaths} - featureRecordsByPath={featureRecordsByPath} - featureLoadingPath={featureLoadingPath} - addingRecordPath={addingRecordPath} - onToggleFeaturePath={_toggleFeaturePath} - onLoadRecordsAtPath={_loadRecordsAtPath} - onAddRecordWithChildren={_addRecordWithChildren} - isRecordAdded={_isRecordAdded} - onSendToChat={onSendToChat_FeatureSource} - featureDataSources={featureDataSources} - onCycleScope={_cycleFeatureScope} - onToggleNeutralize={_toggleFeatureNeutralize} - onToggleNeutralizeField={_toggleNeutralizeField} - featureTree={featureTree} - /> - ))} - { - _loadConnections(); - _fetchDataSources(); - }} onClose={() => setSettingsModal(null)} + onSaved={() => setSettingsModal(null)} />
); }; -/* ─── TreeNodeView (recursive — Browse Sources side) ─────────────────── */ - -function _findDs(dataSources: UdbDataSource[], node: TreeNode): UdbDataSource | undefined { - // Discriminator per node level: - // - connection (Level 1): sourceType = authority string ('msft', 'google', 'clickup', ...) - // - service / folder / file (Level 2+): sourceType from _SERVICE_TO_SOURCE_TYPE mapping - // sourceType is mandatory — otherwise a Level 1 connection DS would shadow Level 2 - // service DS sharing the same connectionId+path='/'. - let expectedSourceType: string | undefined; - if (node.type === 'connection') { - expectedSourceType = node.authority || undefined; - } else if (node.service) { - expectedSourceType = _SERVICE_TO_SOURCE_TYPE[node.service] || node.service; - } - if (!expectedSourceType) return undefined; - return dataSources.find(ds => - ds.connectionId === node.connectionId && - ds.path === (node.path || '/') && - ds.sourceType === expectedSourceType, - ); -} - -// Connection-root DataSources carry the authority as their sourceType. -// They sit above all service DataSources of the same connection in the -// visual tree, so inheritance crosses sourceType for that specific case. -const _AUTHORITY_SOURCE_TYPES = new Set(['local', 'google', 'msft', 'clickup', 'infomaniak']); - -/** Nearest ancestor DS in the connection — same-sourceType path-prefix first, - * connection-root (sourceType = authority, path='/') as the cross-tree fallback. */ -function _findAncestorDs(dataSources: UdbDataSource[], ds: UdbDataSource): UdbDataSource | undefined { - const sameType = dataSources.filter(d => - d.id !== ds.id && - d.connectionId === ds.connectionId && - d.sourceType === ds.sourceType && - ds.path !== d.path && - (d.path === '/' ? ds.path !== '/' : ds.path.startsWith(d.path + '/')) - ); - sameType.sort((a, b) => b.path.length - a.path.length); - if (sameType[0]) return sameType[0]; - - const dsIsConnectionRoot = _AUTHORITY_SOURCE_TYPES.has(ds.sourceType) && ds.path === '/'; - if (dsIsConnectionRoot) return undefined; - return dataSources.find(d => - d.id !== ds.id && - d.connectionId === ds.connectionId && - d.path === '/' && - _AUTHORITY_SOURCE_TYPES.has(d.sourceType) - ); -} - -/** Resolve effective value of a flag: own (if explicit) → ancestor chain → static default. */ -function _effectiveFlag( - ds: UdbDataSource | undefined, - dataSources: UdbDataSource[], - flag: K, -): UdbDataSource[K] { - const fallback = (flag === 'scope' ? 'personal' : false) as UdbDataSource[K]; - if (!ds) return fallback; - const own = ds[flag]; - if (own !== null && own !== undefined && own !== '') return own; - const ancestor = _findAncestorDs(dataSources, ds); - if (ancestor) return _effectiveFlag(ancestor, dataSources, flag); - return fallback; -} - -interface _TreeNodeViewProps { - node: TreeNode; - depth: number; - onToggle: (node: TreeNode) => void; - isAdded: (connectionId: string, service: string | undefined, path: string | undefined) => boolean; - addingPath: string | null; - dataSources: UdbDataSource[]; - onCycleScopeOnNode: (node: TreeNode, currentEffective: string | undefined) => void; - onToggleNeutralizeOnNode: (node: TreeNode, currentEffective: boolean) => void; - onToggleRagIndexOnNode: (node: TreeNode, currentEffective: boolean) => void; - onOpenSettings: (node: TreeNode) => void; - onSendToChat?: (params: { connectionId: string; sourceType: string; path: string; label: string; displayPath?: string }) => void; - scopeCycleTitle: (scope: string) => string; - selectedKeys: Set; - onSelect: (node: TreeNode, e: React.MouseEvent) => void; - inheritedScope?: string; - inheritedNeutralize?: boolean; - inheritedRagIndex?: boolean; -} - -const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({ - node, depth, onToggle, isAdded, addingPath, - dataSources, onCycleScopeOnNode, onToggleNeutralizeOnNode, onToggleRagIndexOnNode, onOpenSettings, onSendToChat, scopeCycleTitle, - selectedKeys, onSelect, inheritedScope, inheritedNeutralize, inheritedRagIndex, -}) => { - const { t } = useLanguage(); - const [hovered, setHovered] = useState(false); - const hasChildren = node.type !== 'file'; - const chevron = hasChildren - ? (node.expanded ? '\u25BE' : '\u25B8') - : '\u00A0\u00A0'; - const ds = _findDs(dataSources, node); - - // Effective values: own (if explicit) → DS-ancestor chain → tree-inherited (UI-only) → default. - // Tree-inherited values cover the case where children don't have their own DS record yet. - const effectiveScope = - (ds && _effectiveFlag(ds, dataSources, 'scope')) ?? inheritedScope ?? 'personal'; - const effectiveNeutralize = - (ds ? (_effectiveFlag(ds, dataSources, 'neutralize') as boolean) : undefined) ?? inheritedNeutralize ?? false; - const effectiveRagIndex = - (ds ? (_effectiveFlag(ds, dataSources, 'ragIndexEnabled') as boolean) : undefined) ?? inheritedRagIndex ?? false; - const childInheritedScope = effectiveScope; - const childInheritedNeutralize = effectiveNeutralize; - const childInheritedRagIndex = effectiveRagIndex; - - const _dragPayload = { - connectionId: node.connectionId, - sourceType: node.service ? (_SERVICE_TO_SOURCE_TYPE[node.service] || node.service) : node.authority || '', - path: node.path || '/', - label: node.label, - displayPath: node.displayPath || node.label, - nodeType: node.type, - }; - - const _chatPayload = { - connectionId: node.connectionId, - sourceType: node.service ? (_SERVICE_TO_SOURCE_TYPE[node.service] || node.service) : node.authority || '', - path: node.path || '/', - label: node.label, - displayPath: node.displayPath || node.label, - }; - - const connColor = ds ? _getSourceColor(ds.sourceType) : undefined; - const isSelected = selectedKeys.has(node.key); - - return ( -
-
{ - if (e.ctrlKey || e.metaKey || e.shiftKey) { - e.stopPropagation(); - onSelect(node, e); - } else if (hasChildren) { - onToggle(node); - } - }} - onMouseEnter={() => setHovered(true)} - onMouseLeave={() => setHovered(false)} - draggable - onDragStart={(e) => { - e.stopPropagation(); - if (selectedKeys.size > 1 && isSelected) { - const items = Array.from(selectedKeys).map(k => ({ key: k, ...(_dragPayload) })); - e.dataTransfer.setData('application/datasource', JSON.stringify(items)); - } else { - e.dataTransfer.setData('application/datasource', JSON.stringify(_dragPayload)); - } - e.dataTransfer.setData('text/plain', node.label); - e.dataTransfer.effectAllowed = 'copy'; - }} - style={{ - display: 'flex', - alignItems: 'center', - gap: 4, - // Compensate the 3px borderLeft on active rows with -3px paddingLeft so - // the row content stays at exactly the same x-position as inactive rows. - paddingLeft: (depth * 16 + 4) - (ds ? 3 : 0), - paddingRight: 4, - paddingTop: 3, - paddingBottom: 3, - cursor: hasChildren ? 'pointer' : 'default', - borderRadius: 3, - background: ds - ? (hovered ? `${connColor}28` : `${connColor}10`) - : isSelected - ? 'var(--selection-bg, rgba(242, 88, 67, 0.12))' - : (hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent'), - borderLeft: ds ? `3px solid ${connColor}` : undefined, - outline: isSelected && !ds ? '1px solid var(--primary-color, #F25843)' : undefined, - transition: 'background 0.1s', - userSelect: 'none', - }} - > - - {node.loading ? _Spinner() : chevron} - - {node.icon} - - {node.label} - - - - - - - - -
- - {node.expanded && node.children && node.children.length > 0 && ( -
- {node.children.map(child => ( - <_TreeNodeView - key={child.key} - node={child} - depth={depth + 1} - onToggle={onToggle} - isAdded={isAdded} - addingPath={addingPath} - dataSources={dataSources} - onCycleScopeOnNode={onCycleScopeOnNode} - onToggleNeutralizeOnNode={onToggleNeutralizeOnNode} - onToggleRagIndexOnNode={onToggleRagIndexOnNode} - onOpenSettings={onOpenSettings} - onSendToChat={onSendToChat} - scopeCycleTitle={scopeCycleTitle} - selectedKeys={selectedKeys} - onSelect={onSelect} - inheritedScope={childInheritedScope} - inheritedNeutralize={childInheritedNeutralize} - inheritedRagIndex={childInheritedRagIndex} - /> - ))} -
- )} - - {node.expanded && node.children && node.children.length === 0 && !node.loading && ( -
- {t('(leer)')} -
- )} -
- ); -}; - -/* ─── Feature-side action props (shared) ─────────────────────────────── */ - -interface _FeatureActionContext { - featureExpandedPaths: Set; - featureRecordsByPath: Record; - featureLoadingPath: string | null; - addingRecordPath: string | null; - onToggleFeaturePath: (pathKey: string) => void; - onLoadRecordsAtPath: ( - node: FeatureConnectionNode, - table: FeatureTableNode, - parentPathSegments: string[], - ) => void; - onAddRecordWithChildren: ( - node: FeatureConnectionNode, - parentTable: FeatureTableNode, - record: ParentRecordNode, - pathSegments: string[], - ) => void; - isRecordAdded: (featureInstanceId: string, parentTableName: string, recordId: string) => boolean; - onAddFeatureTable: ( - node: FeatureConnectionNode, - table: FeatureTableNode, - extra?: { recordFilter?: Record; labelOverride?: string }, - ) => Promise; - isTableAdded: (featureInstanceId: string, tableName: string) => boolean; - addingKey: string | null; - onSendToChat?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string; fieldName?: string }) => void; - featureDataSources: UdbFeatureDataSource[]; - onCycleScope: (fds: UdbFeatureDataSource, currentEffective?: string) => void; - onToggleNeutralize: (fds: UdbFeatureDataSource, currentEffective?: boolean) => void; - onToggleNeutralizeField: (fds: UdbFeatureDataSource, fieldName: string) => void; - featureTree: MandateGroupNode[]; -} - -/* ─── MandateGroupView (mandate + feature instances) ─────────────────── */ - -interface _MandateGroupViewProps extends _FeatureActionContext { - group: MandateGroupNode; - onToggleGroup: (mandateId: string) => void; - onToggleFeature: (node: FeatureConnectionNode) => void; -} - -const _MandateGroupView: React.FC<_MandateGroupViewProps> = (props) => { - const { group, onToggleGroup, onToggleFeature, ...ctx } = props; - const [hovered, setHovered] = useState(false); - const chevron = group.expanded ? '\u25BE' : '\u25B8'; - - return ( -
-
onToggleGroup(group.mandateId)} - onMouseEnter={() => setHovered(true)} - onMouseLeave={() => setHovered(false)} - style={{ - display: 'flex', alignItems: 'center', gap: 4, - paddingLeft: 4, paddingRight: 4, paddingTop: 3, paddingBottom: 3, - cursor: 'pointer', borderRadius: 3, - background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent', - transition: 'background 0.1s', userSelect: 'none', - }} - > - - {chevron} - - - {group.mandateLabel} - -
- - {group.expanded && ( -
- {group.featureConnections.map(fNode => ( - <_FeatureNodeView - key={fNode.featureInstanceId} - node={fNode} - onToggle={onToggleFeature} - {...ctx} - /> - ))} -
- )} -
- ); -}; - -/* ─── FeatureNodeView (feature instance + recursive items) ───────────── */ - -interface _FeatureNodeViewProps extends _FeatureActionContext { - node: FeatureConnectionNode; - onToggle: (node: FeatureConnectionNode) => void; -} - -const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = (props) => { - const { node, onToggle, ...ctx } = props; - const { t } = useLanguage(); - const [hovered, setHovered] = useState(false); - const chevron = node.expanded ? '\u25BE' : '\u25B8'; - - const wildcardFds = ctx.featureDataSources.find( - f => f.featureInstanceId === node.featureInstanceId && f.tableName === '*' && !f.recordFilter, - ); - - const topItems = useMemo( - () => _buildTopFeatureTree(node.tables || []), - [node.tables], - ); - - return ( -
-
onToggle(node)} - onMouseEnter={() => setHovered(true)} - onMouseLeave={() => setHovered(false)} - draggable - onDragStart={(e) => { - e.stopPropagation(); - const payload = JSON.stringify({ - featureInstanceId: node.featureInstanceId, - featureCode: node.featureCode, - objectKey: `data.feature.${node.featureCode}.*`, - label: node.label, - }); - e.dataTransfer.setData('application/feature-source', payload); - e.dataTransfer.setData('text/plain', node.label); - e.dataTransfer.effectAllowed = 'copy'; - }} - style={{ - display: 'flex', alignItems: 'center', gap: 4, - // Compensate the 3px borderLeft on active wildcard rows with -3px - // paddingLeft so the row content stays at the same x-position. - paddingLeft: wildcardFds ? 1 : 4, - paddingRight: 4, paddingTop: 3, paddingBottom: 3, - cursor: 'pointer', borderRadius: 3, - background: wildcardFds - ? (hovered ? '#ede7f6' : '#7b1fa208') - : (hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent'), - borderLeft: wildcardFds ? '3px solid #7b1fa2' : undefined, - transition: 'background 0.1s', userSelect: 'none', - }} - > - - {node.loading ? _Spinner() : chevron} - - - {getPageIcon(`feature.${node.featureCode}`) || '\uD83D\uDDC3\uFE0F'} - - - {node.label} - - - {node.tableCount} {t('Tabellen')} - - - - - - -
- - {node.expanded && topItems.length > 0 && ( -
- {topItems.map((item, idx) => ( - <_FeatureItemView - key={_itemKey(item, idx)} - featureNode={node} - item={item} - pathSegments={[]} - depth={1} - inheritedScope={wildcardFds?.scope ?? undefined} - inheritedNeutralize={wildcardFds?.neutralize ?? undefined} - {...ctx} - /> - ))} -
- )} - - {node.expanded && (node.tables?.length ?? 0) === 0 && !node.loading && ( -
- {t('(keine Tabellen)')} -
- )} -
- ); -}; - -function _itemKey(item: FeatureItem, idx: number): string { - if (item.kind === 'group') return `g:${item.objectKey}-${idx}`; - if (item.kind === 'parentGroup') return `p:${item.table.tableName}-${idx}`; - return `t:${item.table.tableName}-${idx}`; -} - -/* ─── FeatureItemView (recursive — handles group / parentGroup / table) ── */ - -interface _FeatureItemViewProps extends _FeatureActionContext { - featureNode: FeatureConnectionNode; - item: FeatureItem; - pathSegments: string[]; - depth: number; - inheritedScope?: string; - inheritedNeutralize?: boolean; -} - -const _FeatureItemView: React.FC<_FeatureItemViewProps> = (props) => { - const { featureNode, item, pathSegments, depth, inheritedScope, inheritedNeutralize, ...ctx } = props; - - if (item.kind === 'group') { - return ( - <_GroupFolderView - featureNode={featureNode} - objectKey={item.objectKey} - label={item.label} - items={item.items} - pathSegments={pathSegments} - depth={depth} - inheritedScope={inheritedScope} - inheritedNeutralize={inheritedNeutralize} - {...ctx} - /> - ); - } - if (item.kind === 'parentGroup') { - return ( - <_ParentGroupView - featureNode={featureNode} - table={item.table} - pathSegments={pathSegments} - depth={depth} - inheritedScope={inheritedScope} - inheritedNeutralize={inheritedNeutralize} - {...ctx} - /> - ); - } - return ( - <_FeatureTableRow - featureNode={featureNode} - table={item.table} - depth={depth} - onAddFeatureTable={ctx.onAddFeatureTable} - onSendToChat={ctx.onSendToChat} - featureDataSources={ctx.featureDataSources} - onCycleScope={ctx.onCycleScope} - onToggleNeutralize={ctx.onToggleNeutralize} - onToggleNeutralizeField={ctx.onToggleNeutralizeField} - featureTree={ctx.featureTree} - inheritedScope={inheritedScope} - inheritedNeutralize={inheritedNeutralize} - /> - ); -}; - -/* ─── GroupFolderView (categorical folder) ───────────────────────────── */ - -interface _GroupFolderViewProps extends _FeatureActionContext { - featureNode: FeatureConnectionNode; - objectKey: string; - label: string; - items: FeatureItem[]; - pathSegments: string[]; - depth: number; - inheritedScope?: string; - inheritedNeutralize?: boolean; -} - -const _GroupFolderView: React.FC<_GroupFolderViewProps> = (props) => { - const { featureNode, objectKey, label, items, pathSegments, depth, inheritedScope, inheritedNeutralize, ...ctx } = props; - const { t } = useLanguage(); - const [hovered, setHovered] = useState(false); - - const segments = [...pathSegments, `g:${objectKey}`]; - const pathKey = _pathKey(featureNode.featureInstanceId, segments); - const expanded = ctx.featureExpandedPaths.has(pathKey); - const chevron = expanded ? '\u25BE' : '\u25B8'; - - // Container-wildcard objectKey: matches every record/table inside this group. - // Pattern lives in the backend workspaceContext-resolver -- the trailing `.*` - // is treated as a glob-prefix so a single FDS row drives chat/scope/neutralize - // for every child without having to add each one individually. - const containerObjectKey = `data.feature.${featureNode.featureCode}.group:${objectKey}.*`; - const wildcardFds = ctx.featureDataSources.find( - f => f.featureInstanceId === featureNode.featureInstanceId && f.objectKey === containerObjectKey, - ); - const _chatPayload = { - featureInstanceId: featureNode.featureInstanceId, - featureCode: featureNode.featureCode, - objectKey: containerObjectKey, - label, - }; - - return ( -
-
ctx.onToggleFeaturePath(pathKey)} - onMouseEnter={() => setHovered(true)} - onMouseLeave={() => setHovered(false)} - draggable - onDragStart={(e) => { - e.stopPropagation(); - e.dataTransfer.setData('application/feature-source', JSON.stringify(_chatPayload)); - e.dataTransfer.setData('text/plain', label); - e.dataTransfer.effectAllowed = 'copy'; - }} - style={{ - display: 'flex', alignItems: 'center', gap: 4, - // Compensate the 3px border on active wildcard rows so the row - // content stays at the same x-position whether or not it's active. - paddingLeft: (depth * 16 + 4) - (wildcardFds ? 3 : 0), - paddingRight: 4, paddingTop: 3, paddingBottom: 3, - cursor: 'pointer', borderRadius: 3, - background: wildcardFds - ? (hovered ? '#ede7f6' : '#7b1fa208') - : (hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent'), - borderLeft: wildcardFds ? '3px solid #7b1fa2' : undefined, - transition: 'background 0.1s', userSelect: 'none', - }} - > - - {chevron} - - {'\uD83D\uDCC1'} - - {label} - - - - - -
- - {expanded && items.length > 0 && ( -
- {items.map((sub, idx) => ( - <_FeatureItemView - key={_itemKey(sub, idx)} - featureNode={featureNode} - item={sub} - pathSegments={segments} - depth={depth + 1} - inheritedScope={inheritedScope} - inheritedNeutralize={inheritedNeutralize} - {...ctx} - /> - ))} -
- )} -
- ); -}; - -/* ─── ParentGroupView (parent table → list of records) ───────────────── */ - -interface _ParentGroupViewProps extends _FeatureActionContext { - featureNode: FeatureConnectionNode; - table: FeatureTableNode; - pathSegments: string[]; - depth: number; - inheritedScope?: string; - inheritedNeutralize?: boolean; -} - -const _ParentGroupView: React.FC<_ParentGroupViewProps> = (props) => { - const { featureNode, table, pathSegments, depth, inheritedScope, inheritedNeutralize, ...ctx } = props; - const { t } = useLanguage(); - const [hovered, setHovered] = useState(false); - - const segments = [...pathSegments, `p:${table.tableName}`]; - const pathKey = _pathKey(featureNode.featureInstanceId, segments); - const expanded = ctx.featureExpandedPaths.has(pathKey); - const loading = ctx.featureLoadingPath === pathKey; - const records = ctx.featureRecordsByPath[pathKey]; - const chevron = expanded ? '\u25BE' : '\u25B8'; - - const childTables = (featureNode.tables || []).filter(c => c.parentTable === table.tableName); - - const _onToggle = () => { - const willExpand = !expanded; - ctx.onToggleFeaturePath(pathKey); - if (willExpand && !records) { - ctx.onLoadRecordsAtPath(featureNode, table, pathSegments); - } - }; - - // Container-wildcard objectKey for the parent group: matches every record in - // ``table`` so a single FDS row drives chat/scope/neutralize for the whole list. - const containerObjectKey = `data.feature.${featureNode.featureCode}.${table.tableName}.*`; - const wildcardFds = ctx.featureDataSources.find( - f => f.featureInstanceId === featureNode.featureInstanceId - && f.tableName === table.tableName - && !f.recordFilter - && f.objectKey === containerObjectKey, - ); - const _chatPayload = { - featureInstanceId: featureNode.featureInstanceId, - featureCode: featureNode.featureCode, - tableName: table.tableName, - objectKey: containerObjectKey, - label: table.label || table.tableName, - }; - - return ( -
-
setHovered(true)} - onMouseLeave={() => setHovered(false)} - draggable - onDragStart={(e) => { - e.stopPropagation(); - e.dataTransfer.setData('application/feature-source', JSON.stringify(_chatPayload)); - e.dataTransfer.setData('text/plain', _chatPayload.label); - e.dataTransfer.effectAllowed = 'copy'; - }} - style={{ - display: 'flex', alignItems: 'center', gap: 4, - paddingLeft: (depth * 16 + 4) - (wildcardFds ? 3 : 0), - paddingRight: 4, paddingTop: 3, paddingBottom: 3, - cursor: 'pointer', borderRadius: 3, - background: wildcardFds - ? (hovered ? '#ede7f6' : '#7b1fa208') - : (hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent'), - borderLeft: wildcardFds ? '3px solid #7b1fa2' : undefined, - transition: 'background 0.1s', userSelect: 'none', - }} - > - - {loading ? _Spinner() : chevron} - - {'\uD83D\uDCC2'} - - {table.label || table.tableName} - - {childTables.length > 0 && ( - - +{childTables.length} {t('Tabellen')} - - )} - - - - -
- - {expanded && records && records.length > 0 && ( -
- {records.map(record => ( - <_RecordRowView - key={record.id} - featureNode={featureNode} - parentTable={table} - record={record} - pathSegments={segments} - depth={depth + 1} - inheritedScope={inheritedScope} - inheritedNeutralize={inheritedNeutralize} - {...ctx} - /> - ))} -
- )} - - {expanded && records && records.length === 0 && !loading && ( -
- {t('(keine Einträge)')} -
- )} -
- ); -}; - -/* ─── RecordRowView (single record + recursive children when expanded) ── */ - -interface _RecordRowViewProps extends _FeatureActionContext { - featureNode: FeatureConnectionNode; - parentTable: FeatureTableNode; - record: ParentRecordNode; - pathSegments: string[]; - depth: number; - inheritedScope?: string; - inheritedNeutralize?: boolean; -} - -const _RecordRowView: React.FC<_RecordRowViewProps> = (props) => { - const { featureNode, parentTable, record, pathSegments, depth, inheritedScope, inheritedNeutralize, ...ctx } = props; - const { t } = useLanguage(); - const [hovered, setHovered] = useState(false); - - const segments = [...pathSegments, `r:${parentTable.tableName}:${record.id}`]; - const pathKey = _pathKey(featureNode.featureInstanceId, segments); - const expanded = ctx.featureExpandedPaths.has(pathKey); - const chevron = expanded ? '\u25BE' : '\u25B8'; - - const fds = ctx.featureDataSources.find( - f => f.featureInstanceId === featureNode.featureInstanceId - && f.tableName === parentTable.tableName - && f.recordFilter?.id === record.id, - ); - - // Effective values: own explicit > inherited from parent FDS in tree. - // null on own fds means "inherit" (cascade-reset by backend). - const effectiveScope: string = (fds?.scope ?? inheritedScope ?? 'personal') as string; - const effectiveNeutralize: boolean = (fds?.neutralize ?? inheritedNeutralize ?? false) as boolean; - - const childItems = useMemo( - () => _childrenForRecord(featureNode.tables || [], parentTable.tableName), - [featureNode.tables, parentTable.tableName], - ); - - const isAdded = ctx.isRecordAdded(featureNode.featureInstanceId, parentTable.tableName, record.id); - const addingKey = `${_pathKey(featureNode.featureInstanceId, pathSegments)}|r:${parentTable.tableName}:${record.id}`; - const isAdding = ctx.addingRecordPath === addingKey; - - const _chatPayload = { - featureInstanceId: featureNode.featureInstanceId, - featureCode: featureNode.featureCode, - tableName: parentTable.tableName, - objectKey: parentTable.objectKey, - label: `${parentTable.label || parentTable.tableName}: ${record.displayLabel}`, - }; - - return ( -
-
ctx.onToggleFeaturePath(pathKey)} - onMouseEnter={() => setHovered(true)} - onMouseLeave={() => setHovered(false)} - draggable - onDragStart={(e) => { - e.stopPropagation(); - const payload = JSON.stringify({ - featureInstanceId: featureNode.featureInstanceId, - featureCode: featureNode.featureCode, - objectKey: parentTable.objectKey, - label: `${parentTable.label || parentTable.tableName}: ${record.displayLabel}`, - }); - e.dataTransfer.setData('application/feature-source', payload); - e.dataTransfer.setData('text/plain', record.displayLabel); - e.dataTransfer.effectAllowed = 'copy'; - }} - style={{ - display: 'flex', alignItems: 'center', gap: 4, - paddingLeft: depth * 16 + 4, paddingRight: 4, paddingTop: 3, paddingBottom: 3, - cursor: 'pointer', borderRadius: 3, - background: fds - ? (hovered ? '#ede7f6' : '#7b1fa208') - : (hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent'), - transition: 'background 0.1s', userSelect: 'none', - }} - title={Object.entries(record.fields).map(([k, v]) => `${k}: ${v}`).join(', ')} - > - - {chevron} - - {'\uD83D\uDCCB'} - - {record.displayLabel} - - - {/* Add record + direct children as data sources (only when not already added). */} - {!fds && !isAdded && ( - - )} - - - {fds ? ( - - ) : ( - - {_SCOPE_ICONS[inheritedScope || 'personal']} - - )} - {fds ? ( - - ) : ( - - {'\uD83D\uDD12'} - - )} -
- - {expanded && childItems.length > 0 && ( -
- {childItems.map((sub, idx) => ( - <_FeatureItemView - key={_itemKey(sub, idx)} - featureNode={featureNode} - item={sub} - pathSegments={segments} - depth={depth + 1} - inheritedScope={effectiveScope} - inheritedNeutralize={effectiveNeutralize} - {...ctx} - /> - ))} -
- )} -
- ); -}; - -/* ─── FeatureTableRow (leaf table) ───────────────────────────────────── */ - -interface _FeatureTableRowProps { - featureNode: FeatureConnectionNode; - table: FeatureTableNode; - depth: number; - onAddFeatureTable: ( - node: FeatureConnectionNode, - table: FeatureTableNode, - extra?: { recordFilter?: Record; labelOverride?: string }, - ) => Promise; - onSendToChat?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string; fieldName?: string }) => void; - featureDataSources: UdbFeatureDataSource[]; - onCycleScope: (fds: UdbFeatureDataSource, currentEffective?: string) => void; - onToggleNeutralize: (fds: UdbFeatureDataSource, currentEffective?: boolean) => void; - onToggleNeutralizeField: (fds: UdbFeatureDataSource, fieldName: string) => void; - featureTree: MandateGroupNode[]; - inheritedScope?: string; - inheritedNeutralize?: boolean; -} - -const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({ - featureNode, table, depth, onAddFeatureTable, onSendToChat, - featureDataSources, onCycleScope, onToggleNeutralize, onToggleNeutralizeField, - featureTree, inheritedScope, inheritedNeutralize, -}) => { - const { t } = useLanguage(); - const [hovered, setHovered] = useState(false); - const [fieldsExpanded, setFieldsExpanded] = useState(false); - const tableLabel = table.label || table.tableName; - - const fds = featureDataSources.find( - f => f.featureInstanceId === featureNode.featureInstanceId - && f.tableName === table.tableName - && !f.recordFilter, - ); - - const effectiveScope = fds?.scope ?? inheritedScope; - const effectiveNeutralize = fds?.neutralize ?? inheritedNeutralize ?? false; - const _chatPayload = { - featureInstanceId: featureNode.featureInstanceId, - featureCode: featureNode.featureCode, - tableName: table.tableName, - objectKey: table.objectKey, - label: tableLabel, - }; - - const resolvedFields = featureTree - ? _findTableFields(featureTree, featureNode.featureInstanceId, table.tableName) - : table.fields; - const neutralizedCount = fds?.neutralizeFields?.length ?? 0; - - return ( -
-
setHovered(true)} - onMouseLeave={() => setHovered(false)} - draggable - onDragStart={(e) => { - e.stopPropagation(); - e.dataTransfer.setData('application/feature-source', JSON.stringify(_chatPayload)); - e.dataTransfer.setData('text/plain', tableLabel); - e.dataTransfer.effectAllowed = 'copy'; - }} - style={{ - display: 'flex', alignItems: 'center', gap: 4, - paddingLeft: depth * 16 + 4, paddingRight: 4, paddingTop: 3, paddingBottom: 3, - borderRadius: 3, - background: fds - ? (hovered ? '#ede7f6' : '#7b1fa208') - : (hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent'), - transition: 'background 0.1s', userSelect: 'none', - }} - title={`${table.tableName}: ${table.fields.join(', ')}`} - > - { e.stopPropagation(); if (resolvedFields.length > 0) setFieldsExpanded(prev => !prev); }} - style={{ fontSize: 10, color: '#888', width: 12, textAlign: 'center', flexShrink: 0, cursor: resolvedFields.length > 0 ? 'pointer' : 'default' }} - > - {resolvedFields.length > 0 ? (fieldsExpanded ? '\u25BE' : '\u25B8') : '\u00A0\u00A0'} - - {'\uD83D\uDCC1'} - - {tableLabel} - {neutralizedCount > 0 && ( - ({neutralizedCount} {t('Felder')}) - )} - - - - - - -
- - {fieldsExpanded && resolvedFields.length > 0 && ( -
- {resolvedFields.map(field => { - const isNeutralized = (fds?.neutralizeFields || []).includes(field); - return ( - <_FeatureFieldRow - key={field} - featureNode={featureNode} - table={table} - fieldName={field} - depth={depth + 1} - isNeutralized={isNeutralized || effectiveNeutralize} - fds={fds} - onToggleNeutralizeField={onToggleNeutralizeField} - onSendToChat={onSendToChat} - inheritedScope={fds?.scope ?? inheritedScope} - /> - ); - })} -
- )} -
- ); -}; - -/* ─── FeatureFieldRow (single field under a table) ────────────────────── */ - -interface _FeatureFieldRowProps { - featureNode: FeatureConnectionNode; - table: FeatureTableNode; - fieldName: string; - depth: number; - isNeutralized: boolean; - fds?: UdbFeatureDataSource; - onToggleNeutralizeField?: (fds: UdbFeatureDataSource, fieldName: string) => void; - onSendToChat?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string; fieldName?: string }) => void; - inheritedScope?: string; -} - -const _FeatureFieldRow: React.FC<_FeatureFieldRowProps> = ({ - featureNode, table, fieldName, depth, isNeutralized, fds, onToggleNeutralizeField, onSendToChat, inheritedScope, -}) => { - const { t } = useLanguage(); - const [hovered, setHovered] = useState(false); - const _chatPayload = { - featureInstanceId: featureNode.featureInstanceId, - featureCode: featureNode.featureCode, - tableName: table.tableName, - objectKey: table.objectKey, - label: `${table.label || table.tableName}.${fieldName}`, - fieldName, - }; - - return ( -
setHovered(true)} - onMouseLeave={() => setHovered(false)} - draggable - onDragStart={(e) => { - e.stopPropagation(); - e.dataTransfer.setData('application/feature-source', JSON.stringify(_chatPayload)); - e.dataTransfer.setData('text/plain', `${table.tableName}.${fieldName}`); - e.dataTransfer.effectAllowed = 'copy'; - }} - style={{ - display: 'flex', alignItems: 'center', gap: 4, - paddingLeft: depth * 16 + 8, paddingRight: 4, paddingTop: 2, paddingBottom: 2, - borderRadius: 3, - background: isNeutralized - ? (hovered ? '#f3e5f5' : '#f3e5f508') - : (hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent'), - transition: 'background 0.1s', userSelect: 'none', - fontSize: 11, - }} - > - {'\u2514'} - - {fieldName} - - - - - {_SCOPE_ICONS[inheritedScope || 'personal']} - - {fds && onToggleNeutralizeField ? ( - - ) : ( - - {'\uD83D\uDD12'} - - )} -
- ); -}; - export default SourcesTab; diff --git a/src/components/UnifiedDataBar/UdbSourcesProvider.tsx b/src/components/UnifiedDataBar/UdbSourcesProvider.tsx new file mode 100644 index 0000000..4d2abfd --- /dev/null +++ b/src/components/UnifiedDataBar/UdbSourcesProvider.tsx @@ -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([ + 'connection', + 'featureNode', +]); + +// --------------------------------------------------------------------------- +// Icon resolution (kept inline; UDB-domain mapping, not Tree-generic) +// --------------------------------------------------------------------------- + +const _AUTHORITY_ICONS: Record = { + msft: , + google: , + clickup: , + infomaniak: , + 'local:ftp': , + 'local:jira': , +}; + +const _SERVICE_ICONS: Record = { + sharepoint: , + onedrive: , + outlook: , + teams: , + drive: , + gmail: , + files: , + kdrive: , + calendar: , + contact: , +}; + +const _KIND_FALLBACK_ICONS: Record = { + synthRoot: , + connection: , + service: , + folder: , + file: , + mandateGroup: , + featureNode: , + fdsTable: , + fdsRecord: , + fdsField: {'\u22EE'}, +}; + +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, +): TreeNode { + const isSynthetic = _isSyntheticContainer(n.kind); + const isFolderLike = n.hasChildren; + + const node: TreeNode = { + 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 { + /** 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(); + + async function _ensureRecord(node: UdbBackendNode): Promise { + 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 { + 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 { + 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, + ): Promise { + for (const id of ids) { + const cached = nodeCache.get(id); + if (!cached) { + console.warn('[UdbSourcesProvider] patch target not in cache', id); + continue; + } + const dsId = await _ensureRecord(cached); + if (!dsId) continue; + 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 = res.data?.attributes ?? {}; + const result = new Map(); + for (const [key, attrs] of Object.entries(raw)) { + result.set(key, { + neutralize: attrs.effectiveNeutralize, + scope: attrs.effectiveScope as ScopeValue | 'mixed', + ragIndexEnabled: attrs.effectiveRagIndexEnabled, + }); + } + return result; + }, + + _diagnosticGetCacheSize() { + return nodeCache.size; + }, + }; +} diff --git a/src/components/UnifiedDataBar/__tests__/UdbSourcesProvider.test.ts b/src/components/UnifiedDataBar/__tests__/UdbSourcesProvider.test.ts new file mode 100644 index 0000000..8fb974e --- /dev/null +++ b/src/components/UnifiedDataBar/__tests__/UdbSourcesProvider.test.ts @@ -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; patch: ReturnType }; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +function _makeBackendNode(overrides: Partial = {}): 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 }, + ); + }); +}); diff --git a/src/hooks/useTreeExpansion.ts b/src/hooks/useTreeExpansion.ts new file mode 100644 index 0000000..9a647c1 --- /dev/null +++ b/src/hooks/useTreeExpansion.ts @@ -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(null); + const saveTimerRef = useRef | null>(null); + const latestRef = useRef(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 }; +} diff --git a/src/pages/RagInventoryPage.module.css b/src/pages/RagInventoryPage.module.css index 8a72cd0..9fbd759 100644 --- a/src/pages/RagInventoryPage.module.css +++ b/src/pages/RagInventoryPage.module.css @@ -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); diff --git a/src/pages/RagInventoryPage.tsx b/src/pages/RagInventoryPage.tsx index fbe06d1..d8f943a 100644 --- a/src/pages/RagInventoryPage.tsx +++ b/src/pages/RagInventoryPage.tsx @@ -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([]); 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 = {}; - 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 = {}; + 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 = () => {
))} - {(inventory.connections || []).length === 0 && ( + {(inventory.featureInstances || []).length > 0 && ( + <> +

+ + {t('Feature-Daten')} +

+ {(inventory.featureInstances || []).map((fi: RagFeatureInstanceDto) => { + const runningJobs = fi.runningJobs || []; + const lastSuccess = fi.lastSuccess; + const lastError = fi.lastError; + + return ( +
+
+ {fi.featureCode} + {fi.label} + {(fi.fileCount > 0 || fi.chunkCount > 0) && ( + + {t('{f} Dateien · {c} Chunks', { f: fi.fileCount, c: fi.chunkCount })} + + )} + + {fi.ragEnabled ? '\uD83E\uDDE0' : '\u2014'} + +
+ + {!fi.ragEnabled && (fi.dataSources || []).length > 0 && ( +
+ {t('RAG-Indexierung ist für keine Datenquelle dieser Feature-Instanz aktiviert. Aktivierung erfolgt in der UDB (Unified Data Bar) der jeweiligen Workspace-Sitzung.')} +
+ )} + + {runningJobs.length > 0 ? ( +
+ + {runningJobs[0].progressMessage || t('Feature-Daten werden synchronisiert...')} +
+ ) : (() => { + const errAt = lastError?.finishedAt ?? 0; + const okAt = lastSuccess?.finishedAt ?? 0; + const errorIsNewer = !!lastError && errAt > okAt; + + if (errorIsNewer) { + return ( +
+ + + {t('Letzter Sync fehlgeschlagen')} ({_formatRelative(errAt)}): {lastError?.errorMessage || t('unbekannter Fehler')} + + +
+ ); + } + + 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 ( +
+ + + {t('Sync erfolgreich')} {_formatRelative(okAt)} + {stats && <> — {stats}} + + +
+ ); + } + + if (fi.ragEnabled) { + return ( +
+ +
+ ); + } + return null; + })()} + +
+ {(fi.dataSources || []).map(ds => ( +
+ {ds.label || ds.tableName} + {ds.featureCode} + {ds.ragIndexEnabled ? '\uD83E\uDDE0' : '\u2014'} +
+ ))} + {(fi.dataSources || []).length === 0 && fi.fileCount === 0 && ( +
{t('Keine Datenquellen konfiguriert')}
+ )} +
+
+ ); + })} + + )} + + {(inventory.connections || []).length === 0 && (inventory.featureInstances || []).length === 0 && (
{t('Keine Daten für diese Sicht vorhanden.')}
)}
diff --git a/src/pages/admin/AdminDemoConfigPage.tsx b/src/pages/admin/AdminDemoConfigPage.tsx index 0c683c8..c64defe 100644 --- a/src/pages/admin/AdminDemoConfigPage.tsx +++ b/src/pages/admin/AdminDemoConfigPage.tsx @@ -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) }); diff --git a/src/pages/views/workspace/ChatStream.tsx b/src/pages/views/workspace/ChatStream.tsx index 070fdd4..01bb66f 100644 --- a/src/pages/views/workspace/ChatStream.tsx +++ b/src/pages/views/workspace/ChatStream.tsx @@ -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 = ({ messages, const bottomRef = useRef(null); const audioQueue = useAudioQueue(); const enqueuedIdsRef = useRef>(new Set()); + const [copiedId, setCopiedId] = useState(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 = ({ messages, }} > {msg.role === 'assistant' && ( -
Assistant
+
+ Assistant + {msg.message && ( + + )} +
)} {msg.role === 'status' ? ( {msg.message} @@ -648,6 +675,15 @@ function _CodeBlock({ }: React.HTMLAttributes & { 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 (
- {match && ( -
- {match[1]} -
- )} +
+ {match && ( + + {match[1]} + + )} + +
(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 (prev ? `${prev} ${refLabel}` : refLabel));
@@ -544,7 +556,6 @@ export const WorkspaceInput = forwardRef = ({ 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) => {
diff --git a/tsconfig.json b/tsconfig.json
index 1ffef60..01490aa 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -2,6 +2,7 @@
   "files": [],
   "references": [
     { "path": "./tsconfig.app.json" },
-    { "path": "./tsconfig.node.json" }
+    { "path": "./tsconfig.node.json" },
+    { "path": "./tsconfig.test.json" }
   ]
 }