fixes rag and workflow
This commit is contained in:
parent
65170d9e4c
commit
1308e6d415
20 changed files with 2272 additions and 2622 deletions
|
|
@ -373,11 +373,18 @@ export async function getDataSourceCostEstimate(
|
|||
});
|
||||
}
|
||||
|
||||
export interface PatchFlagResponse {
|
||||
sourceId: string;
|
||||
resetDescendantIds: string[];
|
||||
updatedAncestors: { id: string; [key: string]: any }[];
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export async function patchDataSourceRagIndex(
|
||||
request: ApiRequestFunction,
|
||||
dataSourceId: string,
|
||||
ragIndexEnabled: boolean | null
|
||||
): Promise<{ sourceId: string; ragIndexEnabled: boolean | null; updated: boolean; cascadedDescendants?: number }> {
|
||||
): Promise<PatchFlagResponse> {
|
||||
return await request({
|
||||
url: `/api/datasources/${dataSourceId}/rag-index`,
|
||||
method: 'patch',
|
||||
|
|
@ -436,8 +443,42 @@ export interface RagConnectionDto {
|
|||
} | null;
|
||||
}
|
||||
|
||||
export interface RagFeatureDataSourceDto {
|
||||
id: string;
|
||||
label: string;
|
||||
tableName: string;
|
||||
featureCode: string;
|
||||
ragIndexEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface RagFeatureInstanceDto {
|
||||
featureInstanceId: string;
|
||||
featureCode: string;
|
||||
label: string;
|
||||
mandateId: string;
|
||||
fileCount: number;
|
||||
chunkCount: number;
|
||||
statusCounts: Record<string, number>;
|
||||
dataSources: RagFeatureDataSourceDto[];
|
||||
ragEnabled: boolean;
|
||||
runningJobs?: {
|
||||
jobId: string;
|
||||
progress: number;
|
||||
progressMessage: string;
|
||||
}[];
|
||||
lastError?: { jobId: string; errorMessage: string; finishedAt: number | null } | null;
|
||||
lastSuccess?: {
|
||||
jobId: string;
|
||||
finishedAt: number | null;
|
||||
indexed: number;
|
||||
skippedDuplicate: number;
|
||||
failed: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface RagInventoryDto {
|
||||
connections: RagConnectionDto[];
|
||||
featureInstances?: RagFeatureInstanceDto[];
|
||||
totals: { files: number; chunks: number; bytes?: number };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { usePrompt } from '../../../hooks/usePrompt';
|
|||
import type {
|
||||
TreeNode,
|
||||
TreeNodeProvider,
|
||||
NodeAction,
|
||||
FormGeneratorTreeProps,
|
||||
Ownership,
|
||||
ScopeValue,
|
||||
|
|
@ -30,10 +31,33 @@ const _SCOPE_EMOJIS: Record<string, string> = {
|
|||
global: '\uD83C\uDF10',
|
||||
};
|
||||
|
||||
const _NEUTRALIZE_EMOJI = '\uD83D\uDD12';
|
||||
const _NEUTRALIZE_ON_EMOJI = '\uD83D\uDD12'; // closed padlock
|
||||
const _NEUTRALIZE_OFF_EMOJI = '\uD83D\uDD13'; // open padlock
|
||||
const _RAG_ON_EMOJI = '\uD83E\uDDE0'; // brain
|
||||
const _RAG_OFF_EMOJI = '\uD83E\uDDE0'; // brain (greyed via CSS filter when off)
|
||||
|
||||
function _nextScope(current: ScopeValue | undefined): ScopeValue {
|
||||
const idx = SCOPE_ORDER.indexOf(current ?? 'personal');
|
||||
/** CSS for the OFF-state of a boolean flag button. We desaturate the colour
|
||||
* emoji and dim it so the on/off transition is obvious at a glance, even
|
||||
* when the on/off glyph itself is similar (e.g. brain vs greyed-brain). */
|
||||
const _OFF_STATE_STYLE: React.CSSProperties = {
|
||||
filter: 'grayscale(1)',
|
||||
opacity: 0.45,
|
||||
};
|
||||
|
||||
/** Uniform symbol for any flag whose effective value is 'mixed' across children. */
|
||||
const _MIXED_SYMBOL = '\u25E9';
|
||||
|
||||
/** Internal action keys reserved by the tree for the built-in flag buttons. */
|
||||
const _ACTION_SCOPE = '__scope__';
|
||||
const _ACTION_NEUTRALIZE = '__neutralize__';
|
||||
const _ACTION_RAG = '__rag__';
|
||||
|
||||
/** Shared empty set; avoids spurious renders when pendingActions has no entry. */
|
||||
const _EMPTY_SET: Set<string> = new Set();
|
||||
|
||||
function _nextScope(current: ScopeValue | 'mixed' | undefined): ScopeValue {
|
||||
if (current === 'mixed' || current === undefined) return 'personal';
|
||||
const idx = SCOPE_ORDER.indexOf(current);
|
||||
return SCOPE_ORDER[(idx + 1) % SCOPE_ORDER.length];
|
||||
}
|
||||
|
||||
|
|
@ -60,6 +84,15 @@ function _buildChildMap<T>(nodes: TreeNode<T>[]): Map<string | '__root__', TreeN
|
|||
}
|
||||
for (const [, children] of map) {
|
||||
children.sort((a, b) => {
|
||||
const aOrd = a.displayOrder;
|
||||
const bOrd = b.displayOrder;
|
||||
if (aOrd !== undefined && bOrd !== undefined) {
|
||||
if (aOrd !== bOrd) return aOrd - bOrd;
|
||||
} else if (aOrd !== undefined) {
|
||||
return -1;
|
||||
} else if (bOrd !== undefined) {
|
||||
return 1;
|
||||
}
|
||||
if (a.type === 'folder' && b.type !== 'folder') return -1;
|
||||
if (a.type !== 'folder' && b.type === 'folder') return 1;
|
||||
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
|
||||
|
|
@ -126,6 +159,8 @@ interface TreeNodeRowProps<T = any> {
|
|||
isDragging: boolean;
|
||||
ownership: Ownership;
|
||||
compact: boolean;
|
||||
selectable: boolean;
|
||||
pendingActions: Set<string>;
|
||||
provider: TreeNodeProvider<T>;
|
||||
onToggleExpand: (id: string) => void;
|
||||
onToggleSelect: (id: string, e: React.MouseEvent) => void;
|
||||
|
|
@ -138,6 +173,9 @@ interface TreeNodeRowProps<T = any> {
|
|||
onSendToChat?: (node: TreeNode<T>) => void;
|
||||
onCycleScope: (node: TreeNode<T>) => void;
|
||||
onToggleNeutralize: (node: TreeNode<T>) => void;
|
||||
onToggleRagIndex: (node: TreeNode<T>) => void;
|
||||
onCreateChild?: (parentId: string) => void;
|
||||
onExtraAction: (nodeId: string, action: NodeAction) => void;
|
||||
onDragStart: (e: React.DragEvent, node: TreeNode<T>) => void;
|
||||
onDragOver: (e: React.DragEvent, node: TreeNode<T>) => void;
|
||||
onDragLeave: (e: React.DragEvent) => void;
|
||||
|
|
@ -154,6 +192,8 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
|
|||
isDragging,
|
||||
ownership,
|
||||
compact,
|
||||
selectable,
|
||||
pendingActions,
|
||||
provider,
|
||||
onToggleExpand,
|
||||
onToggleSelect,
|
||||
|
|
@ -166,6 +206,9 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
|
|||
onSendToChat,
|
||||
onCycleScope,
|
||||
onToggleNeutralize,
|
||||
onToggleRagIndex,
|
||||
onCreateChild,
|
||||
onExtraAction,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
|
|
@ -231,6 +274,12 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
|
|||
const canDelete = isOwn && provider.canDelete?.(node);
|
||||
const canPatchScope = isOwn && provider.canPatchScope?.(node);
|
||||
const canPatchNeutralize = isOwn && provider.canPatchNeutralize?.(node);
|
||||
const canPatchRagIndex = isOwn && provider.canPatchRagIndex?.(node);
|
||||
const canCreateChild =
|
||||
isOwn &&
|
||||
!!provider.createChild &&
|
||||
node.type === 'folder' &&
|
||||
(provider.canCreate ? provider.canCreate(node.id) : true);
|
||||
|
||||
const rowClasses = [
|
||||
styles.nodeRow,
|
||||
|
|
@ -263,17 +312,19 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
|
|||
>
|
||||
<div className={styles.indentSpacer} style={{ width: depth * INDENT_PX }} />
|
||||
|
||||
<input
|
||||
type="checkbox"
|
||||
className={styles.nodeCheckbox}
|
||||
checked={isSelected}
|
||||
onChange={() => {}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleSelect(node.id, e as unknown as React.MouseEvent);
|
||||
}}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
{selectable && (
|
||||
<input
|
||||
type="checkbox"
|
||||
className={styles.nodeCheckbox}
|
||||
checked={isSelected}
|
||||
onChange={() => {}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleSelect(node.id, e as unknown as React.MouseEvent);
|
||||
}}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasChildren ? (
|
||||
<span
|
||||
|
|
@ -316,6 +367,17 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
|
|||
</span>
|
||||
|
||||
<div className={styles.nodeActionsHover}>
|
||||
{canCreateChild && onCreateChild && (
|
||||
<button
|
||||
className={styles.emojiBtn}
|
||||
onClick={(e) => { e.stopPropagation(); onCreateChild(node.id); }}
|
||||
title="Neuer Unterordner"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{'\u2795'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{canRename && (
|
||||
<button
|
||||
className={styles.emojiBtn}
|
||||
|
|
@ -327,7 +389,7 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
|
|||
</button>
|
||||
)}
|
||||
|
||||
{node.type !== 'folder' && (
|
||||
{node.type !== 'folder' && provider.downloadNode && (
|
||||
<button
|
||||
className={styles.emojiBtn}
|
||||
onClick={(e) => { e.stopPropagation(); onDownload(node); }}
|
||||
|
|
@ -352,6 +414,49 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
|
|||
</div>
|
||||
|
||||
<div className={styles.nodeActionsPersistent}>
|
||||
{/* Order (left-to-right): extraActions (e.g. settings) -> RAG -> sendToChat -> scope -> neutralize. */}
|
||||
{node.extraActions?.map((action) => (
|
||||
<button
|
||||
key={action.key}
|
||||
className={`${styles.emojiBtn} ${action.disabled ? styles.emojiBtnReadonly : ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!action.disabled) onExtraAction(node.id, action);
|
||||
}}
|
||||
title={action.tooltip}
|
||||
tabIndex={-1}
|
||||
disabled={action.disabled}
|
||||
>
|
||||
{pendingActions.has(action.key)
|
||||
? <span className={styles.flagSpinner} />
|
||||
: action.value === 'mixed'
|
||||
? <span className={styles.flagMixed}>{_MIXED_SYMBOL}</span>
|
||||
: action.icon}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{node.ragIndexEnabled !== undefined && (
|
||||
<button
|
||||
className={`${styles.emojiBtn} ${canPatchRagIndex ? '' : styles.emojiBtnReadonly}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (canPatchRagIndex) onToggleRagIndex(node);
|
||||
}}
|
||||
title={node.ragIndexEnabled === 'mixed'
|
||||
? 'Gemischt - Klick setzt explizit'
|
||||
: node.ragIndexEnabled ? 'RAG-Indexierung an' : 'RAG-Indexierung aus'}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{pendingActions.has(_ACTION_RAG)
|
||||
? <span className={styles.flagSpinner} />
|
||||
: node.ragIndexEnabled === 'mixed'
|
||||
? <span className={styles.flagMixed}>{_MIXED_SYMBOL}</span>
|
||||
: node.ragIndexEnabled === true
|
||||
? _RAG_ON_EMOJI
|
||||
: <span style={_OFF_STATE_STYLE}>{_RAG_OFF_EMOJI}</span>}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onSendToChat && (
|
||||
<button
|
||||
className={styles.emojiBtn}
|
||||
|
|
@ -373,10 +478,14 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
|
|||
e.stopPropagation();
|
||||
if (canPatchScope) onCycleScope(node);
|
||||
}}
|
||||
title={`Scope: ${node.scope}`}
|
||||
title={node.scope === 'mixed' ? 'Gemischt - Klick setzt explizit' : `Scope: ${node.scope}`}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{_SCOPE_EMOJIS[node.scope] ?? _SCOPE_EMOJIS.personal}
|
||||
{pendingActions.has(_ACTION_SCOPE)
|
||||
? <span className={styles.flagSpinner} />
|
||||
: node.scope === 'mixed'
|
||||
? <span className={styles.flagMixed}>{_MIXED_SYMBOL}</span>
|
||||
: (_SCOPE_EMOJIS[node.scope] ?? _SCOPE_EMOJIS.personal)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
|
@ -387,11 +496,18 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
|
|||
e.stopPropagation();
|
||||
if (canPatchNeutralize) onToggleNeutralize(node);
|
||||
}}
|
||||
title={node.neutralize ? 'Neutralisiert' : 'Nicht neutralisiert'}
|
||||
title={node.neutralize === 'mixed'
|
||||
? 'Gemischt - Klick setzt explizit'
|
||||
: node.neutralize ? 'Neutralisiert' : 'Nicht neutralisiert'}
|
||||
tabIndex={-1}
|
||||
style={{ opacity: node.neutralize ? 1 : 0.35 }}
|
||||
>
|
||||
{_NEUTRALIZE_EMOJI}
|
||||
{pendingActions.has(_ACTION_NEUTRALIZE)
|
||||
? <span className={styles.flagSpinner} />
|
||||
: node.neutralize === 'mixed'
|
||||
? <span className={styles.flagMixed}>{_MIXED_SYMBOL}</span>
|
||||
: node.neutralize === true
|
||||
? _NEUTRALIZE_ON_EMOJI
|
||||
: <span style={_OFF_STATE_STYLE}>{_NEUTRALIZE_OFF_EMOJI}</span>}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -413,6 +529,8 @@ export function FormGeneratorTree<T = any>({
|
|||
onRefresh,
|
||||
onSendToChat,
|
||||
allowCreateFolder = true,
|
||||
selectable = true,
|
||||
refreshAfterAction = false,
|
||||
className,
|
||||
}: FormGeneratorTreeProps<T>) {
|
||||
const { t } = useLanguage();
|
||||
|
|
@ -428,11 +546,103 @@ export function FormGeneratorTree<T = any>({
|
|||
const [dragOverId, setDragOverId] = useState<string | null>(null);
|
||||
const [draggingIds, setDraggingIds] = useState<Set<string>>(new Set());
|
||||
const [filterText, setFilterText] = useState('');
|
||||
/** Map of nodeId -> set of action keys currently pending (for spinner rendering). */
|
||||
const [pendingActions, setPendingActions] = useState<Map<string, Set<string>>>(new Map());
|
||||
const lastSelectedIdRef = useRef<string | null>(null);
|
||||
const treeContentRef = useRef<HTMLDivElement>(null);
|
||||
/** Tracks node ids for which auto-expand has already fired (one-shot). */
|
||||
const autoExpandedRef = useRef<Set<string>>(new Set());
|
||||
/** Stable ref to the current flatEntries so _refreshVisibleAttributes can
|
||||
* read visible IDs without being in the dependency array. */
|
||||
const flatEntriesRef = useRef<FlatEntry<T>[]>([]);
|
||||
|
||||
/** Deduplicating node append: merges `incoming` into `prev` by id. */
|
||||
const _mergeNodes = useCallback(
|
||||
(prev: TreeNode<T>[], incoming: TreeNode<T>[]): TreeNode<T>[] => {
|
||||
if (incoming.length === 0) return prev;
|
||||
const existingIds = new Set(prev.map((n) => n.id));
|
||||
const unique = incoming.filter((n) => !existingIds.has(n.id));
|
||||
if (unique.length === 0) return prev;
|
||||
return [...prev, ...unique];
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
|
||||
/** After a toggle, collect all currently visible node IDs and ask the
|
||||
* provider for their updated attributes. Patches only attribute fields
|
||||
* (neutralize, scope, ragIndexEnabled) on existing nodes — no structural
|
||||
* reload. Falls back to full refetch if provider doesn't implement
|
||||
* refreshAttributes. */
|
||||
const _refreshVisibleAttributes = useCallback(async () => {
|
||||
if (provider.refreshAttributes) {
|
||||
const visibleIds = flatEntriesRef.current.map((e) => e.node.id);
|
||||
if (visibleIds.length === 0) return;
|
||||
const attrs = await provider.refreshAttributes(visibleIds);
|
||||
setNodes((prev) =>
|
||||
prev.map((n) => {
|
||||
const update = attrs.get(n.id);
|
||||
if (!update) return n;
|
||||
const patched: Partial<typeof n> = {};
|
||||
if (n.neutralize !== undefined && update.neutralize !== undefined) patched.neutralize = update.neutralize;
|
||||
if (n.scope !== undefined && update.scope !== undefined) patched.scope = update.scope;
|
||||
if (n.ragIndexEnabled !== undefined && update.ragIndexEnabled !== undefined) patched.ragIndexEnabled = update.ragIndexEnabled;
|
||||
if (Object.keys(patched).length === 0) return n;
|
||||
return { ...n, ...patched };
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
const expandedList: (string | null)[] = [null, ...Array.from(expandedIds)];
|
||||
const fetched = await Promise.all(
|
||||
expandedList.map((p) => provider.loadChildren(p, ownership)),
|
||||
);
|
||||
const refetchedParents = new Set(expandedList.map((p) => p ?? '__null__'));
|
||||
setNodes((prev) => {
|
||||
const keepers = prev.filter((n) => {
|
||||
const key = n.parentId ?? '__null__';
|
||||
return !refetchedParents.has(key);
|
||||
});
|
||||
return [...keepers, ...fetched.flat()];
|
||||
});
|
||||
}
|
||||
}, [expandedIds, provider, ownership]);
|
||||
|
||||
/** Wrap any async action with pending-state tracking so the tree can show
|
||||
* a spinner over the corresponding button. Generic — no domain knowledge.
|
||||
* When `refreshAfterAction` is enabled, the spinner stays on until the
|
||||
* refreshed attributes have been written into state. */
|
||||
const _runAction = useCallback(
|
||||
async (nodeId: string, actionKey: string, fn: () => Promise<void> | void) => {
|
||||
setPendingActions((prev) => {
|
||||
const next = new Map(prev);
|
||||
const current = new Set(next.get(nodeId) ?? []);
|
||||
current.add(actionKey);
|
||||
next.set(nodeId, current);
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
await fn();
|
||||
if (refreshAfterAction || provider.refreshAttributes) {
|
||||
await _refreshVisibleAttributes();
|
||||
}
|
||||
} finally {
|
||||
setPendingActions((prev) => {
|
||||
const next = new Map(prev);
|
||||
const current = new Set(next.get(nodeId) ?? []);
|
||||
current.delete(actionKey);
|
||||
if (current.size === 0) next.delete(nodeId);
|
||||
else next.set(nodeId, current);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[refreshAfterAction, _refreshVisibleAttributes],
|
||||
);
|
||||
|
||||
const _loadRoot = useCallback(async () => {
|
||||
setLoading(true);
|
||||
autoExpandedRef.current.clear();
|
||||
setExpandedIds(new Set());
|
||||
try {
|
||||
const rootNodes = await provider.loadChildren(null, ownership);
|
||||
setNodes(rootNodes);
|
||||
|
|
@ -448,6 +658,45 @@ export function FormGeneratorTree<T = any>({
|
|||
_loadRoot();
|
||||
}, [_loadRoot]);
|
||||
|
||||
/** Auto-expand nodes with `defaultExpanded=true` from backend, one-shot per id.
|
||||
* Fetches children first, then sets expandedIds + merges atomically so the
|
||||
* expanded arrow never appears without visible children. */
|
||||
useEffect(() => {
|
||||
const targets = nodes.filter(
|
||||
(n) => n.defaultExpanded === true && !autoExpandedRef.current.has(n.id),
|
||||
);
|
||||
if (targets.length === 0) return;
|
||||
const targetIds = targets.map((t) => t.id);
|
||||
for (const id of targetIds) autoExpandedRef.current.add(id);
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const childMap = _buildChildMap(nodes);
|
||||
const toFetch = targetIds.filter((id) => {
|
||||
const existing = childMap.get(id);
|
||||
return !existing || existing.length === 0;
|
||||
});
|
||||
if (toFetch.length > 0) {
|
||||
const results = await Promise.all(
|
||||
toFetch.map((id) =>
|
||||
provider.loadChildren(id, ownership).catch(() => [] as TreeNode<T>[]),
|
||||
),
|
||||
);
|
||||
if (cancelled) return;
|
||||
const flat = results.flat();
|
||||
if (flat.length > 0) {
|
||||
setNodes((prev) => _mergeNodes(prev, flat));
|
||||
}
|
||||
}
|
||||
if (cancelled) return;
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const id of targetIds) next.add(id);
|
||||
return next;
|
||||
});
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [nodes, provider, ownership, _mergeNodes]);
|
||||
|
||||
const flatEntriesRaw = useMemo(() => _flatten(nodes, expandedIds), [nodes, expandedIds]);
|
||||
|
||||
const flatEntries = useMemo(() => {
|
||||
|
|
@ -468,6 +717,8 @@ export function FormGeneratorTree<T = any>({
|
|||
return flatEntriesRaw.filter((e) => matchIds.has(e.node.id));
|
||||
}, [flatEntriesRaw, filterText, nodes]);
|
||||
|
||||
flatEntriesRef.current = flatEntries;
|
||||
|
||||
const _updateSelection = useCallback(
|
||||
(newSelection: Set<string>) => {
|
||||
setSelectedIds(newSelection);
|
||||
|
|
@ -479,32 +730,39 @@ export function FormGeneratorTree<T = any>({
|
|||
const _handleToggleExpand = useCallback(
|
||||
async (id: string) => {
|
||||
const wasExpanded = expandedIds.has(id);
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
const node = nodes.find((n) => n.id === id);
|
||||
if (node && !wasExpanded) {
|
||||
const childMap = _buildChildMap(nodes);
|
||||
const existingChildren = childMap.get(id);
|
||||
if (!existingChildren || existingChildren.length === 0) {
|
||||
const childNodes = await provider.loadChildren(id, ownership);
|
||||
if (childNodes.length > 0) {
|
||||
setNodes((prev) => [...prev, ...childNodes]);
|
||||
if (wasExpanded) {
|
||||
// Collapse: remove all descendants from nodes state and expandedIds.
|
||||
const descendantIds = new Set<string>();
|
||||
const _collectDescendants = (parentId: string) => {
|
||||
for (const n of nodes) {
|
||||
if (n.parentId === parentId && !descendantIds.has(n.id)) {
|
||||
descendantIds.add(n.id);
|
||||
_collectDescendants(n.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
_collectDescendants(id);
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
for (const did of descendantIds) next.delete(did);
|
||||
return next;
|
||||
});
|
||||
setNodes((prev) => prev.filter((n) => !descendantIds.has(n.id)));
|
||||
} else {
|
||||
// Expand: load children from backend (always fresh).
|
||||
setExpandedIds((prev) => new Set([...prev, id]));
|
||||
const childNodes = await provider.loadChildren(id, ownership);
|
||||
if (childNodes.length > 0) {
|
||||
setNodes((prev) => _mergeNodes(prev, childNodes));
|
||||
}
|
||||
setTimeout(() => {
|
||||
_scrollExpandedNodeToCenter(id);
|
||||
}, 50);
|
||||
}
|
||||
},
|
||||
[nodes, expandedIds, provider, ownership],
|
||||
[nodes, expandedIds, provider, ownership, _mergeNodes],
|
||||
);
|
||||
|
||||
const _scrollExpandedNodeToCenter = useCallback((nodeId: string) => {
|
||||
|
|
@ -523,6 +781,10 @@ export function FormGeneratorTree<T = any>({
|
|||
|
||||
const _handleToggleSelect = useCallback(
|
||||
(id: string, e: React.MouseEvent) => {
|
||||
if (!selectable) {
|
||||
setFocusedId(id);
|
||||
return;
|
||||
}
|
||||
const newSelection = new Set(selectedIds);
|
||||
|
||||
if (e.shiftKey && lastSelectedIdRef.current) {
|
||||
|
|
@ -566,7 +828,7 @@ export function FormGeneratorTree<T = any>({
|
|||
lastSelectedIdRef.current = id;
|
||||
_updateSelection(newSelection);
|
||||
},
|
||||
[selectedIds, flatEntries, nodes, ownership, _updateSelection],
|
||||
[selectable, selectedIds, flatEntries, nodes, ownership, _updateSelection],
|
||||
);
|
||||
|
||||
const _handleNodeClick = useCallback(
|
||||
|
|
@ -603,18 +865,23 @@ export function FormGeneratorTree<T = any>({
|
|||
onRefresh?.();
|
||||
}, [_loadRoot, _updateSelection, onRefresh]);
|
||||
|
||||
const _handleNewFolder = useCallback(async () => {
|
||||
/** Create a new folder under `parentId`. `null` = legacy top-level (the
|
||||
* provider may map this to its own visible root, e.g. a synth-root). */
|
||||
const _createFolderAt = useCallback(async (parentId: string | null) => {
|
||||
if (ownership !== 'own' || !provider.createChild || !allowCreateFolder) return;
|
||||
const parentId = _resolveNewFolderParentId(selectedIds, nodes);
|
||||
if (provider.canCreate && !provider.canCreate(parentId)) return;
|
||||
const name = await prompt('Neuer Ordnername:', { title: 'Neuer Ordner', placeholder: 'Ordnername' });
|
||||
const trimmed = name?.trim();
|
||||
if (!trimmed) return;
|
||||
try {
|
||||
const newNode = await provider.createChild(parentId, trimmed);
|
||||
setNodes((prev) => [...prev, newNode]);
|
||||
if (parentId) {
|
||||
setExpandedIds((prev) => new Set(prev).add(parentId));
|
||||
setNodes((prev) => _mergeNodes(prev, [newNode]));
|
||||
// The provider may have re-parented `newNode` (e.g. onto a synth-root)
|
||||
// when `parentId === null`; expand whichever parent the resulting node
|
||||
// actually points at, so the new folder is visible.
|
||||
const visibleParent = newNode.parentId ?? null;
|
||||
if (visibleParent) {
|
||||
setExpandedIds((prev) => new Set(prev).add(visibleParent));
|
||||
}
|
||||
} catch {
|
||||
await _handleRefresh();
|
||||
|
|
@ -623,12 +890,15 @@ export function FormGeneratorTree<T = any>({
|
|||
ownership,
|
||||
provider,
|
||||
allowCreateFolder,
|
||||
selectedIds,
|
||||
nodes,
|
||||
prompt,
|
||||
_handleRefresh,
|
||||
]);
|
||||
|
||||
const _handleNewFolder = useCallback(async () => {
|
||||
const parentId = _resolveNewFolderParentId(selectedIds, nodes);
|
||||
await _createFolderAt(parentId);
|
||||
}, [_createFolderAt, selectedIds, nodes]);
|
||||
|
||||
const _handleDelete = useCallback(
|
||||
async (id: string) => {
|
||||
const node = nodes.find((n) => n.id === id);
|
||||
|
|
@ -659,29 +929,43 @@ export function FormGeneratorTree<T = any>({
|
|||
async (node: TreeNode<T>) => {
|
||||
const newScope = _nextScope(node.scope);
|
||||
const isFolder = node.type === 'folder';
|
||||
await provider.patchScope?.([node.id], newScope, isFolder);
|
||||
setNodes((prev) => {
|
||||
if (!isFolder) return prev.map((n) => (n.id === node.id ? { ...n, scope: newScope } : n));
|
||||
const descendantIds = new Set(_collectDescendantIds(node.id, prev));
|
||||
descendantIds.add(node.id);
|
||||
return prev.map((n) => descendantIds.has(n.id) ? { ...n, scope: newScope } : n);
|
||||
await _runAction(node.id, _ACTION_SCOPE, async () => {
|
||||
await provider.patchScope?.([node.id], newScope, isFolder);
|
||||
});
|
||||
},
|
||||
[provider],
|
||||
[provider, _runAction],
|
||||
);
|
||||
|
||||
const _handleToggleNeutralize = useCallback(
|
||||
async (node: TreeNode<T>) => {
|
||||
const newValue = !node.neutralize;
|
||||
await provider.patchNeutralize?.([node.id], newValue);
|
||||
setNodes((prev) => {
|
||||
if (node.type !== 'folder') return prev.map((n) => (n.id === node.id ? { ...n, neutralize: newValue } : n));
|
||||
const descendantIds = new Set(_collectDescendantIds(node.id, prev));
|
||||
descendantIds.add(node.id);
|
||||
return prev.map((n) => descendantIds.has(n.id) ? { ...n, neutralize: newValue } : n);
|
||||
const newValue = node.neutralize === 'mixed' ? false : !node.neutralize;
|
||||
await _runAction(node.id, _ACTION_NEUTRALIZE, async () => {
|
||||
await provider.patchNeutralize?.([node.id], newValue);
|
||||
});
|
||||
},
|
||||
[provider],
|
||||
[provider, _runAction],
|
||||
);
|
||||
|
||||
const _handleToggleRagIndex = useCallback(
|
||||
async (node: TreeNode<T>) => {
|
||||
const newValue = node.ragIndexEnabled === 'mixed' ? false : !node.ragIndexEnabled;
|
||||
await _runAction(node.id, _ACTION_RAG, async () => {
|
||||
await provider.patchRagIndex?.([node.id], newValue);
|
||||
});
|
||||
},
|
||||
[provider, _runAction],
|
||||
);
|
||||
|
||||
/** Generic dispatcher for provider-defined extraActions. Tree knows nothing
|
||||
* about the action semantics; it only manages the pending spinner. */
|
||||
const _handleExtraAction = useCallback(
|
||||
async (nodeId: string, action: NodeAction) => {
|
||||
if (!action.onClick) return;
|
||||
await _runAction(nodeId, action.key, async () => {
|
||||
await action.onClick!();
|
||||
});
|
||||
},
|
||||
[_runAction],
|
||||
);
|
||||
|
||||
const _handleDragStart = useCallback(
|
||||
|
|
@ -700,10 +984,14 @@ export function FormGeneratorTree<T = any>({
|
|||
e.dataTransfer.setData('application/tree-items', JSON.stringify(chatPayload));
|
||||
e.dataTransfer.setData('text/plain', chatPayload.map((p) => p.name).join(', '));
|
||||
|
||||
if (provider.customizeDragData) {
|
||||
provider.customizeDragData(node, e.dataTransfer);
|
||||
}
|
||||
|
||||
e.dataTransfer.effectAllowed = 'copyMove';
|
||||
setDraggingIds(new Set(dragIds));
|
||||
},
|
||||
[selectedIds, nodes, provider.rootKey],
|
||||
[selectedIds, nodes, provider],
|
||||
);
|
||||
|
||||
const _handleDragOver = useCallback(
|
||||
|
|
@ -948,7 +1236,7 @@ export function FormGeneratorTree<T = any>({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{selectedIds.size > 0 && batchActions.length > 0 && (
|
||||
{selectable && selectedIds.size > 0 && batchActions.length > 0 && (
|
||||
<div className={styles.batchToolbar}>
|
||||
<span className={styles.batchCount}>{selectedIds.size} selected</span>
|
||||
{batchActions.map((action: TreeBatchAction) => {
|
||||
|
|
@ -1009,6 +1297,8 @@ export function FormGeneratorTree<T = any>({
|
|||
isDragging={draggingIds.has(entry.node.id)}
|
||||
ownership={ownership}
|
||||
compact={compact}
|
||||
selectable={selectable}
|
||||
pendingActions={pendingActions.get(entry.node.id) ?? _EMPTY_SET}
|
||||
provider={provider}
|
||||
onToggleExpand={_handleToggleExpand}
|
||||
onToggleSelect={_handleToggleSelect}
|
||||
|
|
@ -1021,6 +1311,9 @@ export function FormGeneratorTree<T = any>({
|
|||
onSendToChat={onSendToChat}
|
||||
onCycleScope={_handleCycleScope}
|
||||
onToggleNeutralize={_handleToggleNeutralize}
|
||||
onToggleRagIndex={_handleToggleRagIndex}
|
||||
onCreateChild={allowCreateFolder ? _createFolderAt : undefined}
|
||||
onExtraAction={_handleExtraAction}
|
||||
onDragStart={_handleDragStart}
|
||||
onDragOver={_handleDragOver}
|
||||
onDragLeave={_handleDragLeave}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,18 @@
|
|||
// Copyright (c) 2026 Patrick Motsch
|
||||
// All rights reserved.
|
||||
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, waitFor, within, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { FormGeneratorTree } from '../FormGeneratorTree';
|
||||
import type { TreeNode, TreeNodeProvider, TreeBatchAction } from '../types';
|
||||
|
||||
// Silence unused-import warnings for React (needed only for the JSX/UMD type
|
||||
// resolution under tsconfig.app fallback paths).
|
||||
void React;
|
||||
|
||||
const { mockPrompt } = vi.hoisted(() => ({
|
||||
mockPrompt: vi.fn(() => Promise.resolve('NeuOrdner')),
|
||||
}));
|
||||
|
|
@ -17,6 +23,27 @@ vi.mock('../../../../hooks/usePrompt', () => ({
|
|||
PromptDialog: () => null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../providers/language/LanguageContext', () => ({
|
||||
useLanguage: () => ({
|
||||
t: (key: string, vars?: Record<string, string>) => {
|
||||
if (!vars) return key;
|
||||
let out = key;
|
||||
for (const [k, v] of Object.entries(vars)) out = out.replace(`{${k}}`, String(v));
|
||||
return out;
|
||||
},
|
||||
availableLanguages: ['de'],
|
||||
language: 'de',
|
||||
setLanguage: () => {},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../hooks/useConfirm', () => ({
|
||||
useConfirm: () => ({
|
||||
confirm: () => Promise.resolve(true),
|
||||
ConfirmDialog: () => null,
|
||||
}),
|
||||
}));
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -68,7 +95,7 @@ const _orphanFile: TreeNode = {
|
|||
function _createMockProvider(nodes: TreeNode[]): TreeNodeProvider {
|
||||
return {
|
||||
rootKey: 'test',
|
||||
loadChildren: vi.fn(async (parentId) =>
|
||||
loadChildren: vi.fn(async (parentId: string | null): Promise<TreeNode[]> =>
|
||||
nodes.filter((n) => n.parentId === parentId),
|
||||
),
|
||||
canCreate: vi.fn(() => true),
|
||||
|
|
@ -77,6 +104,7 @@ function _createMockProvider(nodes: TreeNode[]): TreeNodeProvider {
|
|||
canMove: vi.fn(() => true),
|
||||
canPatchScope: vi.fn((node) => node.ownership === 'own'),
|
||||
canPatchNeutralize: vi.fn((node) => node.ownership === 'own'),
|
||||
canPatchRagIndex: vi.fn((node) => node.ownership === 'own'),
|
||||
createChild: vi.fn(async (parentId, name) => ({
|
||||
id: 'new-1',
|
||||
name,
|
||||
|
|
@ -90,6 +118,7 @@ function _createMockProvider(nodes: TreeNode[]): TreeNodeProvider {
|
|||
moveNodes: vi.fn(async () => {}),
|
||||
patchScope: vi.fn(async () => {}),
|
||||
patchNeutralize: vi.fn(async () => {}),
|
||||
patchRagIndex: vi.fn(async () => {}),
|
||||
getBatchActions: vi.fn(() => []),
|
||||
};
|
||||
}
|
||||
|
|
@ -119,7 +148,7 @@ describe('FormGeneratorTree', () => {
|
|||
|
||||
it('shows loading spinner while loading', () => {
|
||||
const provider = _createMockProvider([]);
|
||||
provider.loadChildren = vi.fn(() => new Promise(() => {})); // never resolves
|
||||
provider.loadChildren = vi.fn(() => new Promise<TreeNode[]>(() => {})); // never resolves
|
||||
render(<FormGeneratorTree provider={provider} ownership="own" />);
|
||||
|
||||
const tree = screen.getByRole('tree');
|
||||
|
|
@ -607,6 +636,7 @@ describe('FormGeneratorTree', () => {
|
|||
expect(provider.patchScope).toHaveBeenCalledWith(
|
||||
['f1'],
|
||||
'featureInstance',
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -780,4 +810,396 @@ describe('FormGeneratorTree', () => {
|
|||
expect(screen.queryByText('Delete All')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mixed-state rendering (generic, used by UDB Sources)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Mixed-state rendering', () => {
|
||||
const _mixedFolder: TreeNode = {
|
||||
id: 'mx1',
|
||||
name: 'Mixed Folder',
|
||||
type: 'folder',
|
||||
parentId: null,
|
||||
ownership: 'own',
|
||||
scope: 'mixed',
|
||||
neutralize: 'mixed',
|
||||
};
|
||||
|
||||
it('renders mixed symbol for scope and neutralize when value is "mixed"', async () => {
|
||||
const provider = _createMockProvider([_mixedFolder]);
|
||||
render(<FormGeneratorTree provider={provider} ownership="own" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Mixed Folder')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Both scope and neutralize buttons share the same mixed tooltip
|
||||
const mixedBtns = screen.getAllByTitle('Gemischt - Klick setzt explizit');
|
||||
expect(mixedBtns).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('clicking mixed scope cycles deterministically to "personal"', async () => {
|
||||
const user = userEvent.setup();
|
||||
const provider = _createMockProvider([_mixedFolder]);
|
||||
render(<FormGeneratorTree provider={provider} ownership="own" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Mixed Folder')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const scopeBtn = screen.getAllByTitle('Gemischt - Klick setzt explizit')[0];
|
||||
await user.click(scopeBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(provider.patchScope).toHaveBeenCalledWith(['mx1'], 'personal', true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generic extraActions slot (used by UDB Sources for RAG toggle + settings)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('extraActions', () => {
|
||||
it('renders extraActions buttons and calls onClick', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClick = vi.fn();
|
||||
const _actionNode: TreeNode = {
|
||||
id: 'a1',
|
||||
name: 'Action Node',
|
||||
type: 'item',
|
||||
parentId: null,
|
||||
ownership: 'own',
|
||||
extraActions: [
|
||||
{ key: 'foo', icon: 'F', tooltip: 'Foo Action', onClick },
|
||||
],
|
||||
};
|
||||
const provider = _createMockProvider([_actionNode]);
|
||||
render(<FormGeneratorTree provider={provider} ownership="own" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Action Node')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByTitle('Foo Action'));
|
||||
await waitFor(() => {
|
||||
expect(onClick).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders mixed symbol for extraAction with value="mixed"', async () => {
|
||||
const _mixedActionNode: TreeNode = {
|
||||
id: 'a2',
|
||||
name: 'Mixed Action Node',
|
||||
type: 'item',
|
||||
parentId: null,
|
||||
ownership: 'own',
|
||||
extraActions: [
|
||||
{ key: 'rag', icon: 'R', tooltip: 'RAG', value: 'mixed' },
|
||||
],
|
||||
};
|
||||
const provider = _createMockProvider([_mixedActionNode]);
|
||||
render(<FormGeneratorTree provider={provider} ownership="own" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Mixed Action Node')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const btn = screen.getByTitle('RAG');
|
||||
expect(btn.textContent).not.toBe('R'); // icon replaced by mixed symbol
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RAG-Index Toggle (third built-in flag)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('RAG-Index toggle', () => {
|
||||
const _ownFolderRag: TreeNode = {
|
||||
..._ownFolder,
|
||||
ragIndexEnabled: false,
|
||||
};
|
||||
|
||||
it('renders RAG button when ragIndexEnabled is defined', async () => {
|
||||
const provider = _createMockProvider([_ownFolderRag]);
|
||||
render(<FormGeneratorTree provider={provider} ownership="own" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('My Folder')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByTitle('RAG-Indexierung aus')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking RAG button calls provider.patchRagIndex with toggled value', async () => {
|
||||
const user = userEvent.setup();
|
||||
const provider = _createMockProvider([_ownFolderRag]);
|
||||
render(<FormGeneratorTree provider={provider} ownership="own" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('My Folder')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByTitle('RAG-Indexierung aus'));
|
||||
await waitFor(() => {
|
||||
expect(provider.patchRagIndex).toHaveBeenCalledWith(['f1'], true);
|
||||
});
|
||||
});
|
||||
|
||||
it('hides RAG button when ragIndexEnabled is undefined (synthetic containers)', async () => {
|
||||
const provider = _createMockProvider([_ownFolder]);
|
||||
render(<FormGeneratorTree provider={provider} ownership="own" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('My Folder')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByTitle('RAG-Indexierung aus')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTitle('RAG-Indexierung an')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders mixed symbol when ragIndexEnabled is "mixed"', async () => {
|
||||
const _mixedRag: TreeNode = { ..._ownFolderRag, ragIndexEnabled: 'mixed' };
|
||||
const provider = _createMockProvider([_mixedRag]);
|
||||
render(<FormGeneratorTree provider={provider} ownership="own" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('My Folder')).toBeInTheDocument();
|
||||
});
|
||||
const mixedBtns = screen.getAllByTitle('Gemischt - Klick setzt explizit');
|
||||
expect(mixedBtns.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('mixed RAG cycles deterministically to false on click', async () => {
|
||||
const user = userEvent.setup();
|
||||
const _mixedRag: TreeNode = {
|
||||
..._ownFolder,
|
||||
scope: 'personal',
|
||||
neutralize: false,
|
||||
ragIndexEnabled: 'mixed',
|
||||
};
|
||||
const provider = _createMockProvider([_mixedRag]);
|
||||
render(<FormGeneratorTree provider={provider} ownership="own" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('My Folder')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const ragBtn = screen.getByTitle('Gemischt - Klick setzt explizit');
|
||||
await user.click(ragBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(provider.patchRagIndex).toHaveBeenCalledWith(['f1'], false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// displayOrder (provider-controlled sorting)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('displayOrder', () => {
|
||||
it('siblings with displayOrder render in numeric ascending order', async () => {
|
||||
const _b: TreeNode = {
|
||||
id: 'b', name: 'Mandanten-Daten', type: 'folder',
|
||||
parentId: null, ownership: 'own', displayOrder: 1,
|
||||
};
|
||||
const _a: TreeNode = {
|
||||
id: 'a', name: 'Persoenliche Quellen', type: 'folder',
|
||||
parentId: null, ownership: 'own', displayOrder: 0,
|
||||
};
|
||||
const provider = _createMockProvider([_b, _a]);
|
||||
render(<FormGeneratorTree provider={provider} ownership="own" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Persoenliche Quellen')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const items = screen.getAllByRole('treeitem');
|
||||
expect(items[0]).toHaveTextContent('Persoenliche Quellen');
|
||||
expect(items[1]).toHaveTextContent('Mandanten-Daten');
|
||||
});
|
||||
|
||||
it('node with displayOrder renders before sibling without (regardless of name)', async () => {
|
||||
const _withOrder: TreeNode = {
|
||||
id: 'wo', name: 'Zzz', type: 'folder',
|
||||
parentId: null, ownership: 'own', displayOrder: 0,
|
||||
};
|
||||
const _withoutOrder: TreeNode = {
|
||||
id: 'no', name: 'Aaa', type: 'folder',
|
||||
parentId: null, ownership: 'own',
|
||||
};
|
||||
const provider = _createMockProvider([_withoutOrder, _withOrder]);
|
||||
render(<FormGeneratorTree provider={provider} ownership="own" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Zzz')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const items = screen.getAllByRole('treeitem');
|
||||
expect(items[0]).toHaveTextContent('Zzz');
|
||||
expect(items[1]).toHaveTextContent('Aaa');
|
||||
});
|
||||
|
||||
it('siblings without displayOrder fall back to folder-first / alphabetic', async () => {
|
||||
const _file: TreeNode = {
|
||||
id: 'fi', name: 'aaa.txt', type: 'file',
|
||||
parentId: null, ownership: 'own',
|
||||
};
|
||||
const _folder: TreeNode = {
|
||||
id: 'fo', name: 'zzz', type: 'folder',
|
||||
parentId: null, ownership: 'own',
|
||||
};
|
||||
const provider = _createMockProvider([_file, _folder]);
|
||||
render(<FormGeneratorTree provider={provider} ownership="own" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('aaa.txt')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const items = screen.getAllByRole('treeitem');
|
||||
expect(items[0]).toHaveTextContent('zzz');
|
||||
expect(items[1]).toHaveTextContent('aaa.txt');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// defaultExpanded (auto-expand hint from provider)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('defaultExpanded', () => {
|
||||
it('auto-expands a node carrying defaultExpanded=true and loads its children', async () => {
|
||||
const _root: TreeNode = {
|
||||
id: 'root', name: 'Root', type: 'folder',
|
||||
parentId: null, ownership: 'own', defaultExpanded: true,
|
||||
};
|
||||
const _child: TreeNode = {
|
||||
id: 'child', name: 'Child', type: 'folder',
|
||||
parentId: 'root', ownership: 'own',
|
||||
};
|
||||
const provider = _createMockProvider([_root, _child]);
|
||||
render(<FormGeneratorTree provider={provider} ownership="own" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Root')).toBeInTheDocument();
|
||||
});
|
||||
// Without auto-expand the child would be hidden until clicking the chevron.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Child')).toBeInTheDocument();
|
||||
});
|
||||
expect(provider.loadChildren).toHaveBeenCalledWith('root', 'own');
|
||||
});
|
||||
|
||||
it('does not auto-expand a node without defaultExpanded', async () => {
|
||||
const _root: TreeNode = {
|
||||
id: 'root', name: 'Root', type: 'folder',
|
||||
parentId: null, ownership: 'own',
|
||||
};
|
||||
const _child: TreeNode = {
|
||||
id: 'child', name: 'Child', type: 'folder',
|
||||
parentId: 'root', ownership: 'own',
|
||||
};
|
||||
const provider = _createMockProvider([_root, _child]);
|
||||
render(<FormGeneratorTree provider={provider} ownership="own" />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Root')).toBeInTheDocument());
|
||||
// Child must NOT appear without manual expand.
|
||||
expect(screen.queryByText('Child')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// refreshAfterAction (backend-authoritative mode)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('refreshAfterAction', () => {
|
||||
it('refetches null + expanded parents after a flag toggle', async () => {
|
||||
const user = userEvent.setup();
|
||||
const provider = _createMockProvider([_ownFolder]);
|
||||
render(
|
||||
<FormGeneratorTree
|
||||
provider={provider}
|
||||
ownership="own"
|
||||
refreshAfterAction
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('My Folder')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const initialLoadCalls = (provider.loadChildren as ReturnType<typeof vi.fn>).mock.calls.length;
|
||||
|
||||
const neutralizeBtn = screen.getByTitle('Nicht neutralisiert');
|
||||
await user.click(neutralizeBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(provider.patchNeutralize).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// After action, at least one extra loadChildren(null, 'own') happened.
|
||||
const newCalls = (provider.loadChildren as ReturnType<typeof vi.fn>).mock.calls;
|
||||
expect(newCalls.length).toBeGreaterThan(initialLoadCalls);
|
||||
expect(newCalls.some(c => c[0] === null && c[1] === 'own')).toBe(true);
|
||||
});
|
||||
|
||||
it('does NOT refetch when refreshAfterAction is false (default)', async () => {
|
||||
const user = userEvent.setup();
|
||||
const provider = _createMockProvider([_ownFolder]);
|
||||
render(<FormGeneratorTree provider={provider} ownership="own" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('My Folder')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const initialLoadCalls = (provider.loadChildren as ReturnType<typeof vi.fn>).mock.calls.length;
|
||||
|
||||
const neutralizeBtn = screen.getByTitle('Nicht neutralisiert');
|
||||
await user.click(neutralizeBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(provider.patchNeutralize).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const newCalls = (provider.loadChildren as ReturnType<typeof vi.fn>).mock.calls.length;
|
||||
expect(newCalls).toBe(initialLoadCalls);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// selectable=false (UDB Sources mode)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('selectable=false', () => {
|
||||
it('hides checkboxes when selectable=false', async () => {
|
||||
const provider = _createMockProvider([_ownFolder]);
|
||||
const { container } = render(
|
||||
<FormGeneratorTree provider={provider} ownership="own" selectable={false} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('My Folder')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(container.querySelector('input[type="checkbox"]')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides batch-action toolbar when selectable=false', async () => {
|
||||
const user = userEvent.setup();
|
||||
const action: TreeBatchAction = {
|
||||
key: 'del',
|
||||
label: 'Delete',
|
||||
onClick: vi.fn(),
|
||||
};
|
||||
const provider = _createMockProvider([_ownFolder]);
|
||||
provider.getBatchActions = vi.fn(() => [action]);
|
||||
|
||||
render(
|
||||
<FormGeneratorTree provider={provider} ownership="own" selectable={false} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('My Folder')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('treeitem', { name: /My Folder/i }));
|
||||
expect(screen.queryByText('Delete')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,6 +12,27 @@ vi.mock('../../../../hooks/usePrompt', () => ({
|
|||
PromptDialog: () => null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../providers/language/LanguageContext', () => ({
|
||||
useLanguage: () => ({
|
||||
t: (key: string, vars?: Record<string, string>) => {
|
||||
if (!vars) return key;
|
||||
let out = key;
|
||||
for (const [k, v] of Object.entries(vars)) out = out.replace(`{${k}}`, String(v));
|
||||
return out;
|
||||
},
|
||||
availableLanguages: ['de'],
|
||||
language: 'de',
|
||||
setLanguage: () => {},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../hooks/useConfirm', () => ({
|
||||
useConfirm: () => ({
|
||||
confirm: () => Promise.resolve(true),
|
||||
ConfirmDialog: () => null,
|
||||
}),
|
||||
}));
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ interface FolderData {
|
|||
name: string;
|
||||
parentId?: string | null;
|
||||
scope?: ScopeValue;
|
||||
neutralize?: boolean;
|
||||
neutralize?: boolean | 'mixed';
|
||||
contextOrphan?: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -52,6 +52,33 @@ function _mapFileToNode(file: FileData, ownership: Ownership): TreeNode {
|
|||
};
|
||||
}
|
||||
|
||||
/** Stable synthetic root id per ownership scope. The real top-level
|
||||
* folders/files attach their `parentId` to this id once we re-parent them
|
||||
* in `loadChildren`. The id stays inside the FE provider; the backend
|
||||
* never sees it. */
|
||||
const _SYNTH_ROOT_ID = (ownership: Ownership): string => `__filesRoot:${ownership}`;
|
||||
|
||||
/** Build the synthetic root node. Its only job is to:
|
||||
* - act as a drop-target for moving items back to top-level,
|
||||
* - expose a global neutralize/scope toggle that cascades to every
|
||||
* top-level descendant.
|
||||
* Its scope/neutralize values are intentionally `undefined` (= "no own
|
||||
* state") — the icons render an indeterminate state and a click sets the
|
||||
* intent on every owned descendant. */
|
||||
function _makeSyntheticRoot(ownership: Ownership): TreeNode {
|
||||
return {
|
||||
id: _SYNTH_ROOT_ID(ownership),
|
||||
name: '/',
|
||||
type: 'folder',
|
||||
parentId: null,
|
||||
ownership,
|
||||
icon: <FaFolder style={{ color: '#666' }} />,
|
||||
defaultExpanded: true,
|
||||
scope: 'personal',
|
||||
neutralize: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function createFolderFileProvider(): TreeNodeProvider {
|
||||
const ownerParam = (ownership: Ownership) => (ownership === 'own' ? 'me' : 'shared');
|
||||
const typeMap = new Map<string, 'folder' | 'file'>();
|
||||
|
|
@ -66,22 +93,74 @@ export function createFolderFileProvider(): TreeNodeProvider {
|
|||
return typeMap.get(id) === 'file';
|
||||
}
|
||||
|
||||
/** When a batch contains the synthetic root id, expand it into the set of
|
||||
* every owned top-level folder + file id. The backend doesn't know the
|
||||
* synthetic root, so we must materialize it client-side. Folder patches
|
||||
* are sent with `cascadeChildren=true` (handled by patchScope) so the
|
||||
* whole subtree is covered without enumerating every descendant here. */
|
||||
async function _expandSyntheticRoots(ids: string[]): Promise<string[]> {
|
||||
const synthIds = ids.filter((id) => id.startsWith('__filesRoot:'));
|
||||
if (synthIds.length === 0) return ids;
|
||||
const out = new Set<string>(ids.filter((id) => !id.startsWith('__filesRoot:')));
|
||||
for (const synthId of synthIds) {
|
||||
const ownership: Ownership = synthId.endsWith(':shared') ? 'shared' : 'own';
|
||||
const owner = ownership === 'own' ? 'me' : 'shared';
|
||||
try {
|
||||
const foldersRes = await api.get('/api/files/folders/tree', { params: { owner } });
|
||||
const allFolders: FolderData[] = foldersRes.data ?? [];
|
||||
for (const f of allFolders) {
|
||||
if ((f.parentId ?? null) === null) out.add(f.id);
|
||||
}
|
||||
const paginationParam = JSON.stringify({ filters: { folderId: null }, pageSize: 500 });
|
||||
const filesRes = await api.get('/api/files/list', { params: { pagination: paginationParam } });
|
||||
const data = filesRes.data;
|
||||
const rawFiles: FileData[] = (data && typeof data === 'object' && 'items' in data)
|
||||
? (Array.isArray(data.items) ? data.items : [])
|
||||
: (Array.isArray(data) ? data : []);
|
||||
for (const f of rawFiles) {
|
||||
if ((f.folderId ?? null) === null) out.add(f.id);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[FolderFileProvider] synthetic-root expansion failed', err);
|
||||
}
|
||||
}
|
||||
return Array.from(out);
|
||||
}
|
||||
|
||||
return {
|
||||
rootKey: 'files',
|
||||
|
||||
async loadChildren(parentId, ownership) {
|
||||
// Synthetic root: when the tree asks for top-level (parentId=null),
|
||||
// we return ONE container ("/") instead of the real items. The real
|
||||
// top-level items are then loaded as children of that container the
|
||||
// next time the tree resolves it (auto-expanded via defaultExpanded).
|
||||
if (parentId === null) {
|
||||
return [_makeSyntheticRoot(ownership)];
|
||||
}
|
||||
|
||||
const synthRootId = _SYNTH_ROOT_ID(ownership);
|
||||
// Backend uses `null` for top-level parents; the FE layer remaps the
|
||||
// synthetic root id back to null before talking to the API.
|
||||
const apiParentId = parentId === synthRootId ? null : parentId;
|
||||
|
||||
const owner = ownerParam(ownership);
|
||||
const nodes: TreeNode[] = [];
|
||||
|
||||
const foldersRes = await api.get('/api/files/folders/tree', { params: { owner } });
|
||||
const allFolders: FolderData[] = foldersRes.data ?? [];
|
||||
const childFolders = allFolders.filter((f) => (f.parentId ?? null) === parentId);
|
||||
nodes.push(...childFolders.map((f) => _mapFolderToNode(f, ownership)));
|
||||
const childFolders = allFolders.filter((f) => (f.parentId ?? null) === apiParentId);
|
||||
const folderNodes = childFolders.map((f) => _mapFolderToNode(f, ownership));
|
||||
// Re-parent top-level folders onto the synthetic root.
|
||||
if (apiParentId === null) {
|
||||
for (const n of folderNodes) n.parentId = synthRootId;
|
||||
}
|
||||
nodes.push(...folderNodes);
|
||||
|
||||
try {
|
||||
const filters: Record<string, any> = {};
|
||||
if (parentId) {
|
||||
filters.folderId = parentId;
|
||||
if (apiParentId) {
|
||||
filters.folderId = apiParentId;
|
||||
}
|
||||
const paginationParam = JSON.stringify({ filters, pageSize: 500 });
|
||||
const filesRes = await api.get('/api/files/list', {
|
||||
|
|
@ -94,12 +173,16 @@ export function createFolderFileProvider(): TreeNodeProvider {
|
|||
} else if (Array.isArray(data)) {
|
||||
rawFiles = data;
|
||||
}
|
||||
let matched = rawFiles.filter((f) => (f.folderId ?? null) === parentId);
|
||||
let matched = rawFiles.filter((f) => (f.folderId ?? null) === apiParentId);
|
||||
if (ownership === 'shared') {
|
||||
const myId = getUserDataCache()?.id;
|
||||
if (myId) matched = matched.filter((f) => f.sysCreatedBy !== myId);
|
||||
}
|
||||
nodes.push(...matched.map((f) => _mapFileToNode(f, ownership)));
|
||||
const fileNodes = matched.map((f) => _mapFileToNode(f, ownership));
|
||||
if (apiParentId === null) {
|
||||
for (const n of fileNodes) n.parentId = synthRootId;
|
||||
}
|
||||
nodes.push(...fileNodes);
|
||||
} catch {
|
||||
// file list may fail for shared trees; folders still render
|
||||
}
|
||||
|
|
@ -113,15 +196,22 @@ export function createFolderFileProvider(): TreeNodeProvider {
|
|||
},
|
||||
|
||||
canRename(node) {
|
||||
// Synthetic "/" root cannot be renamed.
|
||||
if (node.id.startsWith('__filesRoot:')) return false;
|
||||
return node.ownership === 'own';
|
||||
},
|
||||
|
||||
canDelete(node) {
|
||||
if (node.id.startsWith('__filesRoot:')) return false;
|
||||
return node.ownership === 'own';
|
||||
},
|
||||
|
||||
canMove(source, target) {
|
||||
// The synthetic root itself never moves.
|
||||
if (source.id.startsWith('__filesRoot:')) return false;
|
||||
if (source.ownership !== 'own') return false;
|
||||
// Allow drops onto the synthetic root (= move to top-level).
|
||||
if (target && target.id.startsWith('__filesRoot:')) return true;
|
||||
if (target && target.type !== 'folder') return false;
|
||||
if (target && target.id === source.id) return false;
|
||||
return true;
|
||||
|
|
@ -136,8 +226,23 @@ export function createFolderFileProvider(): TreeNodeProvider {
|
|||
},
|
||||
|
||||
async createChild(parentId, name) {
|
||||
const res = await api.post('/api/files/folders', { name, parentId });
|
||||
return _mapFolderToNode(res.data, 'own');
|
||||
// Creating a folder under "/" means a top-level folder; map back to null
|
||||
// for the API. The FE-only synth-root id never travels to the backend.
|
||||
const apiParentId = parentId && parentId.startsWith('__filesRoot:') ? null : parentId;
|
||||
const res = await api.post('/api/files/folders', { name, parentId: apiParentId });
|
||||
const node = _mapFolderToNode(res.data, 'own');
|
||||
// Bind the new folder visually to the parent the user actually clicked.
|
||||
// - explicit synth-root parentId -> attach there ("/" + new top-level folder)
|
||||
// - explicit parent (real folder) -> the API echoes the same parentId
|
||||
// - parentId === null (no clicked parent, e.g. global "+" with no
|
||||
// selection): default to the OWN tree's synth-root so the new folder
|
||||
// shows up inside "/" instead of at the legacy top-level row.
|
||||
if (parentId && parentId.startsWith('__filesRoot:')) {
|
||||
node.parentId = parentId;
|
||||
} else if (parentId === null) {
|
||||
node.parentId = _SYNTH_ROOT_ID('own');
|
||||
}
|
||||
return node;
|
||||
},
|
||||
|
||||
async renameNode(id, newName) {
|
||||
|
|
@ -156,21 +261,29 @@ export function createFolderFileProvider(): TreeNodeProvider {
|
|||
},
|
||||
|
||||
async moveNodes(ids, targetParentId) {
|
||||
// Synth-root drop = move to top-level (folderId/parentId = null).
|
||||
const apiTarget = targetParentId && targetParentId.startsWith('__filesRoot:')
|
||||
? null
|
||||
: targetParentId;
|
||||
await Promise.all(
|
||||
ids.map((id) => {
|
||||
if (_isFile(id)) return api.put(`/api/files/${id}`, { folderId: targetParentId });
|
||||
return api.post(`/api/files/folders/${id}/move`, { targetParentId });
|
||||
if (id.startsWith('__filesRoot:')) return Promise.resolve();
|
||||
if (_isFile(id)) return api.put(`/api/files/${id}`, { folderId: apiTarget });
|
||||
return api.post(`/api/files/folders/${id}/move`, { targetParentId: apiTarget });
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
async patchScope(ids, scope, cascadeChildren) {
|
||||
// Synth-root toggle: cascade across every owned top-level folder/file.
|
||||
const expandedIds = await _expandSyntheticRoots(ids);
|
||||
await Promise.all(
|
||||
ids.map((id) => {
|
||||
expandedIds.map((id) => {
|
||||
if (_isFile(id)) return api.patch(`/api/files/${id}/scope`, { scope });
|
||||
return api.patch(`/api/files/folders/${id}/scope`, { scope, cascadeChildren });
|
||||
return api.patch(`/api/files/folders/${id}/scope`, { scope, cascadeChildren: true });
|
||||
}),
|
||||
);
|
||||
void cascadeChildren;
|
||||
},
|
||||
|
||||
async downloadNode(node) {
|
||||
|
|
@ -185,14 +298,28 @@ export function createFolderFileProvider(): TreeNodeProvider {
|
|||
},
|
||||
|
||||
async patchNeutralize(ids, neutralize) {
|
||||
const expandedIds = await _expandSyntheticRoots(ids);
|
||||
await Promise.all(
|
||||
ids.map((id) => {
|
||||
expandedIds.map((id) => {
|
||||
if (_isFile(id)) return api.patch(`/api/files/${id}/neutralize`, { neutralize });
|
||||
return api.patch(`/api/files/folders/${id}/neutralize`, { neutralize });
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
async refreshAttributes(ids: string[]) {
|
||||
const res = await api.post('/api/files/attributes', { ids });
|
||||
const raw: Record<string, { neutralize?: boolean | 'mixed'; scope?: string | 'mixed' }> = res.data ?? {};
|
||||
const result = new Map<string, { neutralize?: boolean | 'mixed'; scope?: ScopeValue | 'mixed' }>();
|
||||
for (const [id, attrs] of Object.entries(raw)) {
|
||||
result.set(id, {
|
||||
neutralize: attrs.neutralize,
|
||||
scope: attrs.scope as ScopeValue | 'mixed',
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
getBatchActions(): TreeBatchAction[] {
|
||||
return [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2,19 +2,50 @@ export type Ownership = 'own' | 'shared';
|
|||
|
||||
export type ScopeValue = 'personal' | 'featureInstance' | 'mandate' | 'global';
|
||||
|
||||
/** Generic action button rendered to the right of a tree node.
|
||||
* Tree does not interpret the action key; it only renders icon, tooltip
|
||||
* and a pending spinner while onClick is running. Provider may set
|
||||
* value = 'mixed' to make the tree show the uniform mixed symbol instead
|
||||
* of the icon. */
|
||||
export interface NodeAction {
|
||||
key: string;
|
||||
icon: React.ReactNode;
|
||||
tooltip: string;
|
||||
value?: boolean | string | 'mixed';
|
||||
disabled?: boolean;
|
||||
onClick?: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface TreeNode<T = any> {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
parentId: string | null;
|
||||
ownership: Ownership;
|
||||
scope?: ScopeValue;
|
||||
neutralize?: boolean;
|
||||
/** Effective scope. 'mixed' means children have differing effective scopes. */
|
||||
scope?: ScopeValue | 'mixed';
|
||||
/** Effective neutralize. 'mixed' means children have differing effective values. */
|
||||
neutralize?: boolean | 'mixed';
|
||||
/** Effective RAG-index flag. 'mixed' means children have differing effective values. */
|
||||
ragIndexEnabled?: boolean | 'mixed';
|
||||
contextOrphan?: boolean;
|
||||
icon?: React.ReactNode;
|
||||
children?: TreeNode<T>[];
|
||||
isLoading?: boolean;
|
||||
sizeBytes?: number;
|
||||
/** Optional sort hint. When defined, the node is placed before any sibling
|
||||
* without a `displayOrder`; among siblings that all carry one, they are
|
||||
* sorted numerically ascending. When omitted, the default folder-first /
|
||||
* alphabetic sort applies. Tree-generic; no domain knowledge required. */
|
||||
displayOrder?: number;
|
||||
/** When true, the tree auto-expands this node the first time it appears in
|
||||
* a load result. Subsequent user interactions (collapse/expand) override
|
||||
* this hint, and re-fetches that re-emit the same id do not re-trigger
|
||||
* auto-expansion. Tree-generic. */
|
||||
defaultExpanded?: boolean;
|
||||
/** Generic extra action buttons. Tree renders them as Icon+Tooltip with
|
||||
* pending spinner on click. Tree has no knowledge of action semantics. */
|
||||
extraActions?: NodeAction[];
|
||||
data?: T;
|
||||
}
|
||||
|
||||
|
|
@ -37,14 +68,31 @@ export interface TreeNodeProvider<T = any> {
|
|||
canMove?(source: TreeNode<T>, target: TreeNode<T> | null): boolean;
|
||||
canPatchScope?(node: TreeNode<T>): boolean;
|
||||
canPatchNeutralize?(node: TreeNode<T>): boolean;
|
||||
canPatchRagIndex?(node: TreeNode<T>): boolean;
|
||||
createChild?(parentId: string | null, name: string): Promise<TreeNode<T>>;
|
||||
renameNode?(id: string, newName: string): Promise<void>;
|
||||
deleteNodes?(ids: string[]): Promise<void>;
|
||||
moveNodes?(ids: string[], targetParentId: string | null): Promise<void>;
|
||||
patchScope?(ids: string[], scope: ScopeValue, cascadeChildren?: boolean): Promise<void>;
|
||||
patchNeutralize?(ids: string[], neutralize: boolean): Promise<void>;
|
||||
patchRagIndex?(ids: string[], ragIndexEnabled: boolean): Promise<void>;
|
||||
downloadNode?(node: TreeNode<T>): Promise<void>;
|
||||
getBatchActions?(): TreeBatchAction[];
|
||||
/** After a toggle action, the tree collects all currently visible node IDs
|
||||
* and calls this method. The provider asks the backend for the current
|
||||
* attribute values (incl. mixed) of exactly those IDs. The tree then
|
||||
* patches only the attribute fields on existing nodes — no structural
|
||||
* reload. If not implemented, the tree falls back to _refetchAllExpanded. */
|
||||
refreshAttributes?(ids: string[]): Promise<Map<string, {
|
||||
neutralize?: boolean | 'mixed';
|
||||
scope?: ScopeValue | 'mixed';
|
||||
ragIndexEnabled?: boolean | 'mixed';
|
||||
}>>;
|
||||
/** Called during drag-start to let the provider inject domain-specific MIME
|
||||
* types into the DataTransfer (e.g. `application/datasource`). The generic
|
||||
* tree always sets `application/tree-items` and `text/plain`; this hook
|
||||
* adds provider-specific formats on top. */
|
||||
customizeDragData?(node: TreeNode<T>, dataTransfer: DataTransfer): void;
|
||||
}
|
||||
|
||||
export interface FormGeneratorTreeProps<T = any> {
|
||||
|
|
@ -62,5 +110,16 @@ export interface FormGeneratorTreeProps<T = any> {
|
|||
onSendToChat?: (node: TreeNode<T>) => void;
|
||||
/** When false, hides "Neuer Ordner" (e.g. map from table file permissions). Default true. */
|
||||
allowCreateFolder?: boolean;
|
||||
/** When false, hides checkboxes, multi-select keyboard bindings and the
|
||||
* batch-action toolbar. Default true (backward compatible). */
|
||||
selectable?: boolean;
|
||||
/** When true, after every flag-toggle / extra-action the tree refetches
|
||||
* children for `null` and every currently expanded id, then atomically
|
||||
* replaces the affected nodes. Optimistic local-state updates are skipped
|
||||
* in this mode -- the backend is the single source of truth.
|
||||
*
|
||||
* Default `false` for backward-compat with FilesTab and other consumers
|
||||
* that rely on the optimistic-update path. */
|
||||
refreshAfterAction?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
|
|||
const [ownTreeKey, setOwnTreeKey] = useState(0);
|
||||
const [sharedTreeKey, setSharedTreeKey] = useState(0);
|
||||
|
||||
|
||||
const _handleNodeClick = useCallback((node: TreeNode) => {
|
||||
if (node.type === 'file') {
|
||||
onFileSelect?.(node.id, node.name);
|
||||
|
|
@ -200,6 +201,7 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
|
|||
title={t('Eigene')}
|
||||
compact={true}
|
||||
showFilter={true}
|
||||
refreshAfterAction
|
||||
onNodeClick={_handleNodeClickWithImport}
|
||||
onSendToChat={_handleSendToChat}
|
||||
/>
|
||||
|
|
@ -211,6 +213,7 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
|
|||
compact={true}
|
||||
collapsible={true}
|
||||
defaultCollapsed={true}
|
||||
refreshAfterAction
|
||||
emptyMessage={t('Keine geteilten Dateien')}
|
||||
onNodeClick={_handleNodeClickWithImport}
|
||||
onSendToChat={_handleSendToChat}
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
.sourcesTab {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
455
src/components/UnifiedDataBar/UdbSourcesProvider.tsx
Normal file
455
src/components/UnifiedDataBar/UdbSourcesProvider.tsx
Normal file
|
|
@ -0,0 +1,455 @@
|
|||
// Copyright (c) 2026 Patrick Motsch
|
||||
// All rights reserved.
|
||||
/**
|
||||
* UdbSourcesProvider — TreeNodeProvider for the UDB Sources tab.
|
||||
*
|
||||
* Single responsibility: translate the backend tree contract
|
||||
* (POST /api/workspace/{instanceId}/tree/children → nodesByParent map) into
|
||||
* the generic TreeNode shape that FormGeneratorTree consumes, and forward
|
||||
* flag PATCHes to the existing /api/datasources/{id}/{flag} endpoints.
|
||||
*
|
||||
* No effective-value computation, no inheritance logic, no mixed-state math:
|
||||
* the backend is the single source of truth. The provider only:
|
||||
* 1. caches the most recently loaded backend node payload per id, so PATCHes
|
||||
* can resolve the implicit DataSource record (creating it lazily when the
|
||||
* backend reports `canBeAdded=true`),
|
||||
* 2. emits stable display ordering via `displayOrder`,
|
||||
* 3. hides flag affordances on synthetic container nodes (synthRoot,
|
||||
* mandateGroup) by leaving the corresponding TreeNode field undefined.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
FaGoogle, FaMicrosoft, FaTasks, FaCloud, FaLink, FaFolder, FaFile, FaEnvelope,
|
||||
FaCloudUploadAlt, FaCalendarAlt, FaComments, FaUser, FaTable, FaDatabase,
|
||||
FaBuilding,
|
||||
} from 'react-icons/fa';
|
||||
import { SiJira } from 'react-icons/si';
|
||||
import api from '../../api';
|
||||
import type { TreeNode, TreeNodeProvider, ScopeValue } from '../FormGenerator/FormGeneratorTree';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backend contract types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type UdbBackendKind =
|
||||
| 'synthRoot'
|
||||
| 'connection' | 'service' | 'folder' | 'file'
|
||||
| 'mandateGroup' | 'featureNode' | 'fdsTable' | 'fdsRecord' | 'fdsField';
|
||||
|
||||
export interface UdbBackendNode {
|
||||
key: string;
|
||||
kind: UdbBackendKind;
|
||||
parentKey: string | null;
|
||||
label: string;
|
||||
icon?: string;
|
||||
hasChildren: boolean;
|
||||
dataSourceId: string | null;
|
||||
modelType: 'DataSource' | 'FeatureDataSource' | null;
|
||||
effectiveNeutralize: boolean | 'mixed';
|
||||
effectiveScope: string;
|
||||
effectiveRagIndexEnabled: boolean | 'mixed';
|
||||
supportsRag: boolean;
|
||||
canBeAdded: boolean;
|
||||
displayOrder?: number;
|
||||
defaultExpanded?: boolean;
|
||||
authority?: string;
|
||||
connectionId?: string;
|
||||
service?: string;
|
||||
sourceType?: string;
|
||||
path?: string;
|
||||
featureInstanceId?: string;
|
||||
featureCode?: string;
|
||||
mandateId?: string;
|
||||
tableName?: string;
|
||||
fieldName?: string;
|
||||
objectKey?: string;
|
||||
displayPath?: string;
|
||||
/** fdsTable-only: persisted list of column names to neutralize (PII mask). */
|
||||
neutralizeFields?: string[];
|
||||
}
|
||||
|
||||
/** Kinds that represent the *root* of a data source (one DataSource record per
|
||||
* node). Settings are only meaningful here; folders/files/services/tables
|
||||
* inherit settings from the root and don't get their own gear icon. */
|
||||
const _DATA_SOURCE_ROOT_KINDS = new Set<UdbBackendKind>([
|
||||
'connection',
|
||||
'featureNode',
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Icon resolution (kept inline; UDB-domain mapping, not Tree-generic)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const _AUTHORITY_ICONS: Record<string, React.ReactNode> = {
|
||||
msft: <FaMicrosoft style={{ color: '#00a4ef', fontSize: 12 }} />,
|
||||
google: <FaGoogle style={{ color: '#4285f4', fontSize: 12 }} />,
|
||||
clickup: <FaTasks style={{ color: '#7b68ee', fontSize: 12 }} />,
|
||||
infomaniak: <FaCloud style={{ color: '#0098db', fontSize: 12 }} />,
|
||||
'local:ftp': <FaLink style={{ color: '#795548', fontSize: 12 }} />,
|
||||
'local:jira': <SiJira style={{ color: '#0052CC', fontSize: 12 }} />,
|
||||
};
|
||||
|
||||
const _SERVICE_ICONS: Record<string, React.ReactNode> = {
|
||||
sharepoint: <FaFolder style={{ color: '#0078d4', fontSize: 11 }} />,
|
||||
onedrive: <FaCloudUploadAlt style={{ color: '#0078d4', fontSize: 11 }} />,
|
||||
outlook: <FaEnvelope style={{ color: '#0078d4', fontSize: 11 }} />,
|
||||
teams: <FaComments style={{ color: '#6264a7', fontSize: 11 }} />,
|
||||
drive: <FaCloudUploadAlt style={{ color: '#4285f4', fontSize: 11 }} />,
|
||||
gmail: <FaEnvelope style={{ color: '#ea4335', fontSize: 11 }} />,
|
||||
files: <FaLink style={{ color: '#795548', fontSize: 11 }} />,
|
||||
kdrive: <FaCloudUploadAlt style={{ color: '#0098db', fontSize: 11 }} />,
|
||||
calendar: <FaCalendarAlt style={{ color: '#888', fontSize: 11 }} />,
|
||||
contact: <FaUser style={{ color: '#888', fontSize: 11 }} />,
|
||||
};
|
||||
|
||||
const _KIND_FALLBACK_ICONS: Record<string, React.ReactNode> = {
|
||||
synthRoot: <FaDatabase style={{ color: '#666', fontSize: 12 }} />,
|
||||
connection: <FaLink style={{ color: '#888', fontSize: 12 }} />,
|
||||
service: <FaFolder style={{ color: '#888', fontSize: 11 }} />,
|
||||
folder: <FaFolder style={{ color: '#888', fontSize: 11 }} />,
|
||||
file: <FaFile style={{ color: '#888', fontSize: 11 }} />,
|
||||
mandateGroup: <FaBuilding style={{ color: '#7b1fa2', fontSize: 12 }} />,
|
||||
featureNode: <FaDatabase style={{ color: '#7b1fa2', fontSize: 11 }} />,
|
||||
fdsTable: <FaTable style={{ color: '#7b1fa2', fontSize: 11 }} />,
|
||||
fdsRecord: <FaFile style={{ color: '#7b1fa2', fontSize: 11 }} />,
|
||||
fdsField: <span style={{ color: '#9e9e9e', fontSize: 10, fontFamily: 'monospace' }}>{'\u22EE'}</span>,
|
||||
};
|
||||
|
||||
function _renderIcon(node: UdbBackendNode): React.ReactNode {
|
||||
if (node.kind === 'connection') {
|
||||
return _AUTHORITY_ICONS[node.icon || ''] ?? _KIND_FALLBACK_ICONS.connection;
|
||||
}
|
||||
if (node.kind === 'service') {
|
||||
return _SERVICE_ICONS[node.icon || ''] ?? _KIND_FALLBACK_ICONS.service;
|
||||
}
|
||||
return _KIND_FALLBACK_ICONS[node.kind] ?? null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Domain rule: which kinds expose flag toggles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Synthetic / structural containers carry no DB record and have no flags.
|
||||
* The provider hides scope/neutralize/ragIndexEnabled for them so the tree
|
||||
* doesn't render dead buttons. */
|
||||
function _isSyntheticContainer(kind: UdbBackendKind): boolean {
|
||||
return kind === 'synthRoot' || kind === 'mandateGroup';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mapping: backend payload -> generic TreeNode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function _mapBackendNode(
|
||||
n: UdbBackendNode,
|
||||
onSettingsClick: (n: UdbBackendNode) => Promise<void> | void,
|
||||
): TreeNode<UdbBackendNode> {
|
||||
const isSynthetic = _isSyntheticContainer(n.kind);
|
||||
const isFolderLike = n.hasChildren;
|
||||
|
||||
const node: TreeNode<UdbBackendNode> = {
|
||||
id: n.key,
|
||||
name: n.label,
|
||||
type: isFolderLike ? 'folder' : 'file',
|
||||
parentId: n.parentKey,
|
||||
ownership: 'own',
|
||||
icon: _renderIcon(n),
|
||||
displayOrder: n.displayOrder,
|
||||
defaultExpanded: n.defaultExpanded,
|
||||
data: n,
|
||||
};
|
||||
|
||||
if (!isSynthetic) {
|
||||
if (n.kind === 'fdsField') {
|
||||
// Fields expose ONLY neutralize (mapped to parent table's
|
||||
// neutralizeFields list). Scope and RAG are not field-level concepts.
|
||||
node.neutralize = n.effectiveNeutralize;
|
||||
} else {
|
||||
node.scope = n.effectiveScope as ScopeValue | 'mixed';
|
||||
node.neutralize = n.effectiveNeutralize;
|
||||
if (n.supportsRag) {
|
||||
node.ragIndexEnabled = n.effectiveRagIndexEnabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (_DATA_SOURCE_ROOT_KINDS.has(n.kind)) {
|
||||
node.extraActions = [{
|
||||
key: 'settings',
|
||||
icon: '\u2699\uFE0F',
|
||||
tooltip: 'Einstellungen',
|
||||
onClick: () => onSettingsClick(n),
|
||||
}];
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface UdbSourcesProviderHandle extends TreeNodeProvider<UdbBackendNode> {
|
||||
/** Test/diagnostic hook only -- exposes the latest cached backend payloads
|
||||
* so consumers can inspect data flow without round-tripping through the
|
||||
* network. Not part of the contract used at runtime. */
|
||||
_diagnosticGetCacheSize(): number;
|
||||
}
|
||||
|
||||
export function createUdbSourcesProvider(
|
||||
instanceId: string,
|
||||
onOpenSettings: (dataSourceId: string, label: string) => void,
|
||||
): UdbSourcesProviderHandle {
|
||||
// Per-id cache of the most recent backend payload. Updated by every
|
||||
// `loadChildren` call. Read by patch/ensureRecord paths.
|
||||
const nodeCache = new Map<string, UdbBackendNode>();
|
||||
|
||||
async function _ensureRecord(node: UdbBackendNode): Promise<string | null> {
|
||||
if (node.dataSourceId) return node.dataSourceId;
|
||||
try {
|
||||
if (node.kind === 'connection' || node.kind === 'service'
|
||||
|| node.kind === 'folder' || node.kind === 'file') {
|
||||
const sourceType = node.sourceType
|
||||
|| (node.kind === 'connection' ? node.authority : '')
|
||||
|| '';
|
||||
const res = await api.post(`/api/workspace/${instanceId}/datasources`, {
|
||||
connectionId: node.connectionId || '',
|
||||
sourceType,
|
||||
path: node.path || '/',
|
||||
label: node.label,
|
||||
displayPath: node.displayPath || node.label,
|
||||
});
|
||||
const newId: string | null = res.data?.id ?? null;
|
||||
if (newId) {
|
||||
nodeCache.set(node.key, { ...node, dataSourceId: newId, modelType: 'DataSource' });
|
||||
}
|
||||
return newId;
|
||||
}
|
||||
if (node.kind === 'featureNode' || node.kind === 'fdsTable' || node.kind === 'fdsRecord') {
|
||||
const tableName = node.tableName || (node.kind === 'featureNode' ? '*' : '');
|
||||
const objectKey = node.objectKey
|
||||
|| (node.kind === 'featureNode' ? `data.feature.${node.featureCode}.*` : '');
|
||||
const res = await api.post(`/api/workspace/${instanceId}/feature-datasources`, {
|
||||
featureInstanceId: node.featureInstanceId || '',
|
||||
featureCode: node.featureCode || '',
|
||||
tableName,
|
||||
objectKey,
|
||||
label: node.label,
|
||||
});
|
||||
const newId: string | null = res.data?.id ?? null;
|
||||
if (newId) {
|
||||
nodeCache.set(node.key, { ...node, dataSourceId: newId, modelType: 'FeatureDataSource' });
|
||||
}
|
||||
return newId;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[UdbSourcesProvider] ensureRecord failed', err);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function _onSettingsClick(node: UdbBackendNode): Promise<void> {
|
||||
const dsId = await _ensureRecord(node);
|
||||
if (!dsId) {
|
||||
console.warn('[UdbSourcesProvider] settings click: cannot ensure record', node.key);
|
||||
return;
|
||||
}
|
||||
onOpenSettings(dsId, node.label);
|
||||
}
|
||||
|
||||
/** fdsField-specific neutralize: ensure the parent fdsTable record exists,
|
||||
* read its current `neutralizeFields` list, add or remove the field,
|
||||
* PATCH the new list back. Backend treats the FDS-record as the single
|
||||
* source of truth for per-field neutralization. */
|
||||
async function _patchFieldNeutralize(fieldNodeId: string, neutralize: boolean): Promise<void> {
|
||||
const fieldNode = nodeCache.get(fieldNodeId);
|
||||
if (!fieldNode || fieldNode.kind !== 'fdsField') {
|
||||
console.warn('[UdbSourcesProvider] field-neutralize target missing', fieldNodeId);
|
||||
return;
|
||||
}
|
||||
const fieldName = fieldNode.fieldName;
|
||||
const featureInstanceId = fieldNode.featureInstanceId;
|
||||
const tableName = fieldNode.tableName;
|
||||
if (!fieldName || !featureInstanceId || !tableName) {
|
||||
console.warn('[UdbSourcesProvider] field-neutralize missing context', fieldNode);
|
||||
return;
|
||||
}
|
||||
// Resolve the parent fdsTable record. Use the node's dataSourceId if
|
||||
// already known (synthesized by the backend); otherwise create the
|
||||
// record via _ensureRecord on a synthetic table-shaped node.
|
||||
let dsId = fieldNode.dataSourceId;
|
||||
if (!dsId) {
|
||||
const tableNode: UdbBackendNode = {
|
||||
...fieldNode,
|
||||
kind: 'fdsTable',
|
||||
key: `fdstbl|${featureInstanceId}|${tableName}`,
|
||||
};
|
||||
dsId = await _ensureRecord(tableNode);
|
||||
}
|
||||
if (!dsId) return;
|
||||
// The parent fdsTable node carries `neutralizeFields` in its payload;
|
||||
// pull it from the cache. Falls back to the field's effective state if
|
||||
// the parent isn't cached for some reason.
|
||||
const tableKey = `fdstbl|${featureInstanceId}|${tableName}`;
|
||||
const tableNode = nodeCache.get(tableKey);
|
||||
const currentList: string[] =
|
||||
tableNode && Array.isArray(tableNode.neutralizeFields)
|
||||
? [...tableNode.neutralizeFields]
|
||||
: [];
|
||||
const set = new Set(currentList);
|
||||
if (neutralize) set.add(fieldName);
|
||||
else set.delete(fieldName);
|
||||
const newList = Array.from(set);
|
||||
try {
|
||||
await api.patch(`/api/datasources/${dsId}/neutralize-fields`, { neutralizeFields: newList });
|
||||
// Keep the cache in sync so subsequent toggles in the same session
|
||||
// start from the right baseline.
|
||||
if (tableNode) {
|
||||
nodeCache.set(tableKey, { ...tableNode, neutralizeFields: newList });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[UdbSourcesProvider] patch neutralize-fields failed', { fieldNodeId, err });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function _patchFlag(
|
||||
ids: string[],
|
||||
flag: 'scope' | 'neutralize' | 'rag-index',
|
||||
body: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
for (const id of ids) {
|
||||
const cached = nodeCache.get(id);
|
||||
if (!cached) {
|
||||
console.warn('[UdbSourcesProvider] patch target not in cache', id);
|
||||
continue;
|
||||
}
|
||||
const dsId = await _ensureRecord(cached);
|
||||
if (!dsId) continue;
|
||||
try {
|
||||
await api.patch(`/api/datasources/${dsId}/${flag}`, body);
|
||||
} catch (err) {
|
||||
console.error('[UdbSourcesProvider] patch failed', { id, flag, err });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
rootKey: `udb-sources-${instanceId}`,
|
||||
|
||||
async loadChildren(parentId, _ownership) {
|
||||
const res = await api.post(`/api/workspace/${instanceId}/tree/children`, {
|
||||
parents: [parentId],
|
||||
});
|
||||
const nodesByParent = res.data?.nodesByParent || {};
|
||||
const lookupKey = parentId ?? '__root__';
|
||||
const list: UdbBackendNode[] = nodesByParent[lookupKey] || [];
|
||||
for (const n of list) nodeCache.set(n.key, n);
|
||||
return list.map((n) => _mapBackendNode(n, _onSettingsClick));
|
||||
},
|
||||
|
||||
canPatchScope(node) {
|
||||
const data = node.data;
|
||||
// Field-level scope makes no sense; it's inherited from the parent table.
|
||||
return !!data && !_isSyntheticContainer(data.kind) && data.kind !== 'fdsField';
|
||||
},
|
||||
|
||||
canPatchNeutralize(node) {
|
||||
const data = node.data;
|
||||
return !!data && !_isSyntheticContainer(data.kind);
|
||||
},
|
||||
|
||||
canPatchRagIndex(node) {
|
||||
const data = node.data;
|
||||
// RAG is not a field-level concept either; only the table-record carries it.
|
||||
return !!data && data.supportsRag === true && data.kind !== 'fdsField';
|
||||
},
|
||||
|
||||
async patchScope(ids, scope, _cascadeChildren) {
|
||||
// Backend cascades NULL on descendants automatically based on the
|
||||
// existence of explicit child records; the cascadeChildren flag is the
|
||||
// FilesTab convention and is irrelevant here.
|
||||
await _patchFlag(ids, 'scope', { scope });
|
||||
},
|
||||
|
||||
async patchNeutralize(ids, neutralize) {
|
||||
// fdsField nodes don't have their own DB record — they are addressed
|
||||
// via the parent fdsTable's `neutralizeFields` array. Split the batch
|
||||
// accordingly and dispatch each kind to the right endpoint.
|
||||
const fieldIds: string[] = [];
|
||||
const otherIds: string[] = [];
|
||||
for (const id of ids) {
|
||||
const cached = nodeCache.get(id);
|
||||
if (cached?.kind === 'fdsField') fieldIds.push(id);
|
||||
else otherIds.push(id);
|
||||
}
|
||||
if (otherIds.length > 0) await _patchFlag(otherIds, 'neutralize', { neutralize });
|
||||
for (const fieldId of fieldIds) await _patchFieldNeutralize(fieldId, neutralize);
|
||||
},
|
||||
|
||||
async patchRagIndex(ids, ragIndexEnabled) {
|
||||
await _patchFlag(ids, 'rag-index', { ragIndexEnabled });
|
||||
},
|
||||
|
||||
customizeDragData(node, dataTransfer) {
|
||||
const data = node.data as UdbBackendNode | undefined;
|
||||
if (!data || _isSyntheticContainer(data.kind)) return;
|
||||
|
||||
if (data.kind === 'connection' || data.kind === 'service'
|
||||
|| data.kind === 'folder' || data.kind === 'file') {
|
||||
const sourceType = data.sourceType
|
||||
|| (data.kind === 'connection' ? data.authority : '') || '';
|
||||
const payload = {
|
||||
connectionId: data.connectionId || '',
|
||||
sourceType,
|
||||
path: data.path || '/',
|
||||
label: data.label,
|
||||
displayPath: data.displayPath || data.label,
|
||||
};
|
||||
dataTransfer.setData('application/datasource', JSON.stringify(payload));
|
||||
} else if (data.kind === 'featureNode' || data.kind === 'fdsTable' || data.kind === 'fdsRecord') {
|
||||
const tableName = data.tableName || (data.kind === 'featureNode' ? '*' : '');
|
||||
const objectKey = data.objectKey
|
||||
|| (data.kind === 'featureNode' ? `data.feature.${data.featureCode}.*` : '');
|
||||
const payload = {
|
||||
featureInstanceId: data.featureInstanceId || '',
|
||||
featureCode: data.featureCode || '',
|
||||
tableName,
|
||||
objectKey,
|
||||
label: data.label,
|
||||
};
|
||||
dataTransfer.setData('application/feature-source', JSON.stringify(payload));
|
||||
}
|
||||
},
|
||||
|
||||
async refreshAttributes(ids: string[]) {
|
||||
const res = await api.post(`/api/workspace/${instanceId}/tree/attributes`, {
|
||||
keys: ids,
|
||||
});
|
||||
const raw: Record<string, {
|
||||
effectiveNeutralize?: boolean | 'mixed';
|
||||
effectiveScope?: string | 'mixed';
|
||||
effectiveRagIndexEnabled?: boolean | 'mixed';
|
||||
}> = res.data?.attributes ?? {};
|
||||
const result = new Map<string, {
|
||||
neutralize?: boolean | 'mixed';
|
||||
scope?: ScopeValue | 'mixed';
|
||||
ragIndexEnabled?: boolean | 'mixed';
|
||||
}>();
|
||||
for (const [key, attrs] of Object.entries(raw)) {
|
||||
result.set(key, {
|
||||
neutralize: attrs.effectiveNeutralize,
|
||||
scope: attrs.effectiveScope as ScopeValue | 'mixed',
|
||||
ragIndexEnabled: attrs.effectiveRagIndexEnabled,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
_diagnosticGetCacheSize() {
|
||||
return nodeCache.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,384 @@
|
|||
// Copyright (c) 2026 Patrick Motsch
|
||||
// All rights reserved.
|
||||
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { createUdbSourcesProvider, type UdbBackendNode } from '../UdbSourcesProvider';
|
||||
|
||||
// Mock the api module that the provider imports.
|
||||
vi.mock('../../../api', () => ({
|
||||
default: {
|
||||
post: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import api from '../../../api';
|
||||
const apiMock = api as unknown as { post: ReturnType<typeof vi.fn>; patch: ReturnType<typeof vi.fn> };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function _makeBackendNode(overrides: Partial<UdbBackendNode> = {}): UdbBackendNode {
|
||||
return {
|
||||
key: 'conn|c1',
|
||||
kind: 'connection',
|
||||
parentKey: 'personalRoot',
|
||||
label: 'My Microsoft',
|
||||
icon: 'msft',
|
||||
hasChildren: true,
|
||||
dataSourceId: null,
|
||||
modelType: null,
|
||||
effectiveNeutralize: false,
|
||||
effectiveScope: 'personal',
|
||||
effectiveRagIndexEnabled: false,
|
||||
supportsRag: true,
|
||||
canBeAdded: true,
|
||||
authority: 'msft',
|
||||
connectionId: 'c1',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function _makeSynthRootNode(): UdbBackendNode {
|
||||
return {
|
||||
key: 'personalRoot',
|
||||
kind: 'synthRoot',
|
||||
parentKey: null,
|
||||
label: 'Persoenliche Quellen',
|
||||
icon: 'person',
|
||||
hasChildren: true,
|
||||
dataSourceId: null,
|
||||
modelType: null,
|
||||
effectiveNeutralize: false,
|
||||
effectiveScope: 'personal',
|
||||
effectiveRagIndexEnabled: false,
|
||||
supportsRag: false,
|
||||
canBeAdded: false,
|
||||
displayOrder: 0,
|
||||
defaultExpanded: true,
|
||||
};
|
||||
}
|
||||
|
||||
const _instanceId = 'inst-42';
|
||||
|
||||
beforeEach(() => {
|
||||
apiMock.post.mockReset();
|
||||
apiMock.patch.mockReset();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// loadChildren
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('UdbSourcesProvider.loadChildren', () => {
|
||||
it('calls POST /api/workspace/{instanceId}/tree/children with parents=[parentId]', async () => {
|
||||
apiMock.post.mockResolvedValue({ data: { nodesByParent: { __root__: [] } } });
|
||||
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
||||
|
||||
await provider.loadChildren(null, 'own');
|
||||
|
||||
expect(apiMock.post).toHaveBeenCalledWith(
|
||||
`/api/workspace/${_instanceId}/tree/children`,
|
||||
{ parents: [null] },
|
||||
);
|
||||
});
|
||||
|
||||
it('maps backend nodes to TreeNode shape with flag-bearer fields', async () => {
|
||||
const conn = _makeBackendNode();
|
||||
apiMock.post.mockResolvedValue({ data: { nodesByParent: { 'personalRoot': [conn] } } });
|
||||
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
||||
|
||||
const result = await provider.loadChildren('personalRoot', 'own');
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
const tn = result[0];
|
||||
expect(tn.id).toBe('conn|c1');
|
||||
expect(tn.name).toBe('My Microsoft');
|
||||
expect(tn.parentId).toBe('personalRoot');
|
||||
expect(tn.ownership).toBe('own');
|
||||
expect(tn.scope).toBe('personal');
|
||||
expect(tn.neutralize).toBe(false);
|
||||
expect(tn.ragIndexEnabled).toBe(false);
|
||||
expect(tn.type).toBe('folder');
|
||||
});
|
||||
|
||||
it('hides scope/neutralize/ragIndexEnabled on synthetic containers', async () => {
|
||||
const root = _makeSynthRootNode();
|
||||
apiMock.post.mockResolvedValue({ data: { nodesByParent: { __root__: [root] } } });
|
||||
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
||||
|
||||
const result = await provider.loadChildren(null, 'own');
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].scope).toBeUndefined();
|
||||
expect(result[0].neutralize).toBeUndefined();
|
||||
expect(result[0].ragIndexEnabled).toBeUndefined();
|
||||
expect(result[0].displayOrder).toBe(0);
|
||||
});
|
||||
|
||||
it('omits ragIndexEnabled when supportsRag is false', async () => {
|
||||
const node = _makeBackendNode({
|
||||
key: 'mgrp|m1',
|
||||
kind: 'mandateGroup',
|
||||
parentKey: null,
|
||||
supportsRag: false,
|
||||
});
|
||||
apiMock.post.mockResolvedValue({ data: { nodesByParent: { __root__: [node] } } });
|
||||
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
||||
|
||||
const result = await provider.loadChildren(null, 'own');
|
||||
|
||||
expect(result[0].ragIndexEnabled).toBeUndefined();
|
||||
expect(result[0].scope).toBeUndefined();
|
||||
expect(result[0].neutralize).toBeUndefined();
|
||||
});
|
||||
|
||||
it('attaches the settings extraAction on every data-source-root, even without a record yet', async () => {
|
||||
const onSettings = vi.fn();
|
||||
const withId = _makeBackendNode({ dataSourceId: 'ds-1', canBeAdded: false });
|
||||
const withoutId = _makeBackendNode({ key: 'conn|c2', dataSourceId: null });
|
||||
apiMock.post.mockResolvedValue({
|
||||
data: { nodesByParent: { personalRoot: [withId, withoutId] } },
|
||||
});
|
||||
const provider = createUdbSourcesProvider(_instanceId, onSettings);
|
||||
|
||||
const result = await provider.loadChildren('personalRoot', 'own');
|
||||
|
||||
expect(result[0].extraActions).toHaveLength(1);
|
||||
expect(result[0].extraActions?.[0].key).toBe('settings');
|
||||
await result[0].extraActions?.[0].onClick?.();
|
||||
expect(onSettings).toHaveBeenCalledWith('ds-1', 'My Microsoft');
|
||||
|
||||
// The conn without a record still gets a settings button (always visible
|
||||
// on data-source-roots). Click triggers an _ensureRecord POST first.
|
||||
expect(result[1].extraActions).toHaveLength(1);
|
||||
expect(result[1].extraActions?.[0].key).toBe('settings');
|
||||
});
|
||||
|
||||
it('hides the settings extraAction on non-root nodes (folders, files, services, ...)', async () => {
|
||||
const folder = _makeBackendNode({ kind: 'folder', dataSourceId: 'ds-9' });
|
||||
apiMock.post.mockResolvedValue({
|
||||
data: { nodesByParent: { 'conn|c1': [folder] } },
|
||||
});
|
||||
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
||||
|
||||
const result = await provider.loadChildren('conn|c1', 'own');
|
||||
expect(result[0].extraActions).toBeUndefined();
|
||||
});
|
||||
|
||||
it('forwards defaultExpanded from backend payload to the TreeNode', async () => {
|
||||
const expanded = _makeBackendNode({
|
||||
key: 'personalRoot',
|
||||
kind: 'synthRoot',
|
||||
defaultExpanded: true,
|
||||
});
|
||||
apiMock.post.mockResolvedValue({
|
||||
data: { nodesByParent: { __root__: [expanded] } },
|
||||
});
|
||||
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
||||
|
||||
const [node] = await provider.loadChildren(null, 'own');
|
||||
expect(node.defaultExpanded).toBe(true);
|
||||
});
|
||||
|
||||
it('populates the internal cache so subsequent patches can resolve nodes', async () => {
|
||||
apiMock.post.mockResolvedValue({
|
||||
data: { nodesByParent: { personalRoot: [_makeBackendNode()] } },
|
||||
});
|
||||
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
||||
|
||||
expect(provider._diagnosticGetCacheSize()).toBe(0);
|
||||
await provider.loadChildren('personalRoot', 'own');
|
||||
expect(provider._diagnosticGetCacheSize()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// canPatch* predicates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('UdbSourcesProvider.canPatch*', () => {
|
||||
it('canPatchScope is false for synthetic containers', async () => {
|
||||
apiMock.post.mockResolvedValue({
|
||||
data: { nodesByParent: { __root__: [_makeSynthRootNode()] } },
|
||||
});
|
||||
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
||||
const [synthNode] = await provider.loadChildren(null, 'own');
|
||||
expect(provider.canPatchScope?.(synthNode)).toBe(false);
|
||||
expect(provider.canPatchNeutralize?.(synthNode)).toBe(false);
|
||||
expect(provider.canPatchRagIndex?.(synthNode)).toBe(false);
|
||||
});
|
||||
|
||||
it('canPatchRagIndex requires supportsRag=true', async () => {
|
||||
apiMock.post.mockResolvedValue({
|
||||
data: {
|
||||
nodesByParent: {
|
||||
personalRoot: [
|
||||
_makeBackendNode({ key: 'a', supportsRag: true }),
|
||||
_makeBackendNode({ key: 'b', supportsRag: false }),
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
||||
const [a, b] = await provider.loadChildren('personalRoot', 'own');
|
||||
expect(provider.canPatchRagIndex?.(a)).toBe(true);
|
||||
expect(provider.canPatchRagIndex?.(b)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// patch flow: ensureRecord + PATCH
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('UdbSourcesProvider.patchScope', () => {
|
||||
it('PATCHes existing dataSourceId without creating a new record', async () => {
|
||||
apiMock.post.mockResolvedValueOnce({
|
||||
data: {
|
||||
nodesByParent: {
|
||||
personalRoot: [_makeBackendNode({ dataSourceId: 'ds-existing', canBeAdded: false })],
|
||||
},
|
||||
},
|
||||
});
|
||||
apiMock.patch.mockResolvedValue({ data: {} });
|
||||
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
||||
await provider.loadChildren('personalRoot', 'own');
|
||||
|
||||
await provider.patchScope?.(['conn|c1'], 'mandate', true);
|
||||
|
||||
expect(apiMock.patch).toHaveBeenCalledWith(
|
||||
`/api/datasources/ds-existing/scope`,
|
||||
{ scope: 'mandate' },
|
||||
);
|
||||
// Only one POST: the loadChildren call. No POST datasources.
|
||||
expect(apiMock.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('creates a DataSource record first when canBeAdded=true', async () => {
|
||||
apiMock.post
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
nodesByParent: {
|
||||
personalRoot: [_makeBackendNode({ dataSourceId: null, canBeAdded: true })],
|
||||
},
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({ data: { id: 'ds-new' } });
|
||||
apiMock.patch.mockResolvedValue({ data: {} });
|
||||
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
||||
await provider.loadChildren('personalRoot', 'own');
|
||||
|
||||
await provider.patchScope?.(['conn|c1'], 'mandate', true);
|
||||
|
||||
expect(apiMock.post).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
`/api/workspace/${_instanceId}/datasources`,
|
||||
expect.objectContaining({
|
||||
connectionId: 'c1',
|
||||
sourceType: 'msft',
|
||||
path: '/',
|
||||
label: 'My Microsoft',
|
||||
}),
|
||||
);
|
||||
expect(apiMock.patch).toHaveBeenCalledWith(
|
||||
`/api/datasources/ds-new/scope`,
|
||||
{ scope: 'mandate' },
|
||||
);
|
||||
});
|
||||
|
||||
it('skips silently when target node is not in cache', async () => {
|
||||
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
||||
await provider.patchScope?.(['unknown'], 'personal', false);
|
||||
expect(apiMock.patch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('UdbSourcesProvider.patchNeutralize', () => {
|
||||
it('PATCHes /neutralize with the supplied boolean', async () => {
|
||||
apiMock.post.mockResolvedValueOnce({
|
||||
data: {
|
||||
nodesByParent: {
|
||||
personalRoot: [_makeBackendNode({ dataSourceId: 'ds-1', canBeAdded: false })],
|
||||
},
|
||||
},
|
||||
});
|
||||
apiMock.patch.mockResolvedValue({ data: {} });
|
||||
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
||||
await provider.loadChildren('personalRoot', 'own');
|
||||
|
||||
await provider.patchNeutralize?.(['conn|c1'], true);
|
||||
|
||||
expect(apiMock.patch).toHaveBeenCalledWith(
|
||||
`/api/datasources/ds-1/neutralize`,
|
||||
{ neutralize: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UdbSourcesProvider.patchRagIndex', () => {
|
||||
it('PATCHes /rag-index with the supplied boolean (note dash in URL, camelCase in body)', async () => {
|
||||
apiMock.post.mockResolvedValueOnce({
|
||||
data: {
|
||||
nodesByParent: {
|
||||
personalRoot: [_makeBackendNode({ dataSourceId: 'ds-1', canBeAdded: false })],
|
||||
},
|
||||
},
|
||||
});
|
||||
apiMock.patch.mockResolvedValue({ data: {} });
|
||||
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
||||
await provider.loadChildren('personalRoot', 'own');
|
||||
|
||||
await provider.patchRagIndex?.(['conn|c1'], true);
|
||||
|
||||
expect(apiMock.patch).toHaveBeenCalledWith(
|
||||
`/api/datasources/ds-1/rag-index`,
|
||||
{ ragIndexEnabled: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('routes to feature-datasources when the cached node is a featureNode', async () => {
|
||||
const featureNode: UdbBackendNode = {
|
||||
key: 'feat|m1|trustee|inst-1',
|
||||
kind: 'featureNode',
|
||||
parentKey: 'mgrp|m1',
|
||||
label: 'Trustee',
|
||||
icon: 'mdi-database',
|
||||
hasChildren: true,
|
||||
dataSourceId: null,
|
||||
modelType: null,
|
||||
effectiveNeutralize: false,
|
||||
effectiveScope: 'personal',
|
||||
effectiveRagIndexEnabled: false,
|
||||
supportsRag: true,
|
||||
canBeAdded: true,
|
||||
featureInstanceId: 'inst-1',
|
||||
featureCode: 'trustee',
|
||||
mandateId: 'm1',
|
||||
tableName: '*',
|
||||
};
|
||||
apiMock.post
|
||||
.mockResolvedValueOnce({ data: { nodesByParent: { 'mgrp|m1': [featureNode] } } })
|
||||
.mockResolvedValueOnce({ data: { id: 'fds-new' } });
|
||||
apiMock.patch.mockResolvedValue({ data: {} });
|
||||
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
||||
await provider.loadChildren('mgrp|m1', 'own');
|
||||
|
||||
await provider.patchRagIndex?.([featureNode.key], true);
|
||||
|
||||
expect(apiMock.post).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
`/api/workspace/${_instanceId}/feature-datasources`,
|
||||
expect.objectContaining({
|
||||
featureInstanceId: 'inst-1',
|
||||
featureCode: 'trustee',
|
||||
tableName: '*',
|
||||
objectKey: 'data.feature.trustee.*',
|
||||
}),
|
||||
);
|
||||
expect(apiMock.patch).toHaveBeenCalledWith(
|
||||
`/api/datasources/fds-new/rag-index`,
|
||||
{ ragIndexEnabled: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
88
src/hooks/useTreeExpansion.ts
Normal file
88
src/hooks/useTreeExpansion.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
// Copyright (c) 2026 Patrick Motsch
|
||||
// All rights reserved.
|
||||
/**
|
||||
* useTreeExpansion - fire-and-forget persistence for tree expand state.
|
||||
*
|
||||
* Simple contract:
|
||||
* - On mount: load saved expandedIds from backend (or null if none).
|
||||
* - Returns the loaded ids (once) so the tree can seed its initial state.
|
||||
* - Provides a `save(ids)` function that debounce-PUTs to the backend.
|
||||
* - No bidirectional state flow, no props, no re-render triggers.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import api from '../api';
|
||||
|
||||
const _SAVE_DEBOUNCE_MS = 600;
|
||||
|
||||
export interface UseTreeExpansionResult {
|
||||
loaded: boolean;
|
||||
initialIds: string[] | null;
|
||||
save: (ids: string[]) => void;
|
||||
}
|
||||
|
||||
export function useTreeExpansion(
|
||||
instanceId: string | null | undefined,
|
||||
scope: string,
|
||||
): UseTreeExpansionResult {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [initialIds, setInitialIds] = useState<string[] | null>(null);
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const latestRef = useRef<string[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!instanceId) {
|
||||
setLoaded(true);
|
||||
setInitialIds(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoaded(false);
|
||||
api
|
||||
.get(`/api/workspace/${instanceId}/ui-tree-expansion/${encodeURIComponent(scope)}`)
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
const fromServer: string[] | null = res.data?.expandedNodes ?? null;
|
||||
setInitialIds(fromServer);
|
||||
latestRef.current = fromServer;
|
||||
setLoaded(true);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
console.warn('[useTreeExpansion] load failed', err);
|
||||
setInitialIds(null);
|
||||
setLoaded(true);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [instanceId, scope]);
|
||||
|
||||
const save = useCallback(
|
||||
(ids: string[]) => {
|
||||
if (!instanceId) return;
|
||||
const sorted = [...ids].sort().join('|');
|
||||
const prevSorted = latestRef.current ? [...latestRef.current].sort().join('|') : null;
|
||||
if (sorted === prevSorted) return;
|
||||
latestRef.current = ids;
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
api
|
||||
.put(
|
||||
`/api/workspace/${instanceId}/ui-tree-expansion/${encodeURIComponent(scope)}`,
|
||||
{ expandedNodes: latestRef.current ?? [] },
|
||||
)
|
||||
.catch((err) => {
|
||||
console.warn('[useTreeExpansion] save failed', err);
|
||||
});
|
||||
}, _SAVE_DEBOUNCE_MS);
|
||||
},
|
||||
[instanceId, scope],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { loaded, initialIds, save };
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -10,9 +10,8 @@
|
|||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { useLanguage } from '../providers/language/LanguageContext';
|
||||
import { useApiRequest } from '../hooks/useApi';
|
||||
import { useUserMandates } from '../hooks/useUserMandates';
|
||||
import type { RagInventoryDto, RagConnectionDto } from '../api/connectionApi';
|
||||
import { FaDatabase, FaStop, FaSync, FaToggleOn, FaToggleOff, FaRedo, FaExclamationTriangle, FaCheckCircle, FaSlidersH } from 'react-icons/fa';
|
||||
import type { RagInventoryDto, RagConnectionDto, RagFeatureInstanceDto } from '../api/connectionApi';
|
||||
import { FaDatabase, FaStop, FaSync, FaToggleOn, FaToggleOff, FaRedo, FaExclamationTriangle, FaCheckCircle, FaSlidersH, FaCubes } from 'react-icons/fa';
|
||||
import { mandateDisplayLabel } from '../utils/mandateDisplayUtils';
|
||||
import { DataSourceSettingsModal } from '../components/UnifiedDataBar/DataSourceSettingsModal';
|
||||
import styles from './RagInventoryPage.module.css';
|
||||
|
|
@ -20,7 +19,6 @@ import styles from './RagInventoryPage.module.css';
|
|||
export const RagInventoryPage: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const { request } = useApiRequest();
|
||||
const { fetchMandates } = useUserMandates();
|
||||
|
||||
const [mandates, setMandates] = useState<any[]>([]);
|
||||
const [mandatesLoading, setMandatesLoading] = useState(true);
|
||||
|
|
@ -54,7 +52,7 @@ export const RagInventoryPage: React.FC = () => {
|
|||
(async () => {
|
||||
setMandatesLoading(true);
|
||||
try {
|
||||
const data = await fetchMandates();
|
||||
const data = await request({ url: '/api/rag/inventory/my-mandates', method: 'get' });
|
||||
if (!cancelled) {
|
||||
const list = Array.isArray(data) ? data : [];
|
||||
setMandates(list);
|
||||
|
|
@ -64,7 +62,7 @@ export const RagInventoryPage: React.FC = () => {
|
|||
finally { if (!cancelled) setMandatesLoading(false); }
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [fetchMandates]);
|
||||
}, [request]);
|
||||
|
||||
const _apiEndpoint = useMemo(() => {
|
||||
if (selectedScope === 'personal') return '/api/rag/inventory/me';
|
||||
|
|
@ -77,11 +75,13 @@ export const RagInventoryPage: React.FC = () => {
|
|||
setError(null);
|
||||
try {
|
||||
const params: Record<string, string> = {};
|
||||
if (selectedScope !== 'personal' && selectedScope !== 'platform') {
|
||||
params.mandateId = selectedScope;
|
||||
}
|
||||
if (onlyMyData) params.onlyMine = 'true';
|
||||
const data = await request({ url: _apiEndpoint, method: 'get', params });
|
||||
const isMandateScope = selectedScope !== 'personal' && selectedScope !== 'platform';
|
||||
const headers: Record<string, string> = {};
|
||||
if (isMandateScope) {
|
||||
headers['X-Mandate-Id'] = selectedScope;
|
||||
}
|
||||
const data = await request({ url: _apiEndpoint, method: 'get', params, additionalConfig: { headers } });
|
||||
setInventory(data);
|
||||
} catch (err: any) {
|
||||
if (err?.message?.includes('403')) {
|
||||
|
|
@ -99,7 +99,10 @@ export const RagInventoryPage: React.FC = () => {
|
|||
_fetchInventory();
|
||||
}, [_fetchInventory]);
|
||||
|
||||
const _hasActiveJobs = !!inventory?.connections?.some(c => (c.runningJobs?.length || 0) > 0);
|
||||
const _hasActiveJobs = !!(
|
||||
inventory?.connections?.some(c => (c.runningJobs?.length || 0) > 0) ||
|
||||
inventory?.featureInstances?.some(fi => (fi.runningJobs?.length || 0) > 0)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
|
|
@ -127,6 +130,13 @@ export const RagInventoryPage: React.FC = () => {
|
|||
} catch {}
|
||||
};
|
||||
|
||||
const _handleReindexFeature = async (workspaceInstanceId: string) => {
|
||||
try {
|
||||
await request({ url: `/api/rag/inventory/reindex-feature/${workspaceInstanceId}`, method: 'post' });
|
||||
_fetchInventory();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const _handleConsentToggle = async (connectionId: string, currentEnabled: boolean) => {
|
||||
if (!currentEnabled || window.confirm(t('Alle indexierten Inhalte dieser Verbindung werden entfernt. Fortfahren?'))) {
|
||||
try {
|
||||
|
|
@ -374,7 +384,118 @@ export const RagInventoryPage: React.FC = () => {
|
|||
</div>
|
||||
))}
|
||||
|
||||
{(inventory.connections || []).length === 0 && (
|
||||
{(inventory.featureInstances || []).length > 0 && (
|
||||
<>
|
||||
<h2 className={styles.sectionTitle}>
|
||||
<FaCubes style={{ marginRight: 8 }} />
|
||||
{t('Feature-Daten')}
|
||||
</h2>
|
||||
{(inventory.featureInstances || []).map((fi: RagFeatureInstanceDto) => {
|
||||
const runningJobs = fi.runningJobs || [];
|
||||
const lastSuccess = fi.lastSuccess;
|
||||
const lastError = fi.lastError;
|
||||
|
||||
return (
|
||||
<div key={fi.featureInstanceId} className={styles.connectionCard}>
|
||||
<div className={styles.connectionHeader}>
|
||||
<span className={styles.authority}>{fi.featureCode}</span>
|
||||
<span className={styles.email}>{fi.label}</span>
|
||||
{(fi.fileCount > 0 || fi.chunkCount > 0) && (
|
||||
<span
|
||||
className={styles.connChunks}
|
||||
title={t('Embedding-Fragmente (~400 Tokens), die der RAG-Retrieval trifft')}
|
||||
>
|
||||
{t('{f} Dateien · {c} Chunks', { f: fi.fileCount, c: fi.chunkCount })}
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.dsIndex} title={fi.ragEnabled ? t('RAG aktiv') : t('RAG inaktiv')}>
|
||||
{fi.ragEnabled ? '\uD83E\uDDE0' : '\u2014'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!fi.ragEnabled && (fi.dataSources || []).length > 0 && (
|
||||
<div className={styles.consentWarning}>
|
||||
{t('RAG-Indexierung ist für keine Datenquelle dieser Feature-Instanz aktiviert. Aktivierung erfolgt in der UDB (Unified Data Bar) der jeweiligen Workspace-Sitzung.')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{runningJobs.length > 0 ? (
|
||||
<div className={styles.jobBanner}>
|
||||
<FaSync className={styles.spinIcon} />
|
||||
<span>{runningJobs[0].progressMessage || t('Feature-Daten werden synchronisiert...')}</span>
|
||||
</div>
|
||||
) : (() => {
|
||||
const errAt = lastError?.finishedAt ?? 0;
|
||||
const okAt = lastSuccess?.finishedAt ?? 0;
|
||||
const errorIsNewer = !!lastError && errAt > okAt;
|
||||
|
||||
if (errorIsNewer) {
|
||||
return (
|
||||
<div className={styles.errorBanner}>
|
||||
<FaExclamationTriangle />
|
||||
<span>
|
||||
{t('Letzter Sync fehlgeschlagen')} ({_formatRelative(errAt)}): {lastError?.errorMessage || t('unbekannter Fehler')}
|
||||
</span>
|
||||
<button className={styles.reindexBtn} onClick={() => _handleReindexFeature(fi.featureInstanceId)} title={t('Neu indexieren')}>
|
||||
<FaRedo size={12} /> {t('Neu indexieren')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (lastSuccess) {
|
||||
const s = lastSuccess;
|
||||
const stats = [
|
||||
s.indexed > 0 ? t('{n} neu indexiert', { n: s.indexed }) : null,
|
||||
s.skippedDuplicate > 0 ? t('{n} unverändert', { n: s.skippedDuplicate }) : null,
|
||||
s.failed > 0 ? t('{n} fehler', { n: s.failed }) : null,
|
||||
].filter(Boolean).join(' · ');
|
||||
|
||||
return (
|
||||
<div className={styles.successBanner}>
|
||||
<FaCheckCircle />
|
||||
<span>
|
||||
{t('Sync erfolgreich')} {_formatRelative(okAt)}
|
||||
{stats && <> — {stats}</>}
|
||||
</span>
|
||||
<button className={styles.reindexBtn} onClick={() => _handleReindexFeature(fi.featureInstanceId)} title={t('Erneut indexieren')}>
|
||||
<FaRedo size={12} /> {t('Erneut indexieren')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (fi.ragEnabled) {
|
||||
return (
|
||||
<div className={styles.reindexHint}>
|
||||
<button className={styles.reindexBtn} onClick={() => _handleReindexFeature(fi.featureInstanceId)} title={t('Indexierung starten')}>
|
||||
<FaRedo size={12} /> {t('Indexierung starten')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
|
||||
<div className={styles.dsList}>
|
||||
{(fi.dataSources || []).map(ds => (
|
||||
<div key={ds.id} className={`${styles.dsRow} ${ds.ragIndexEnabled ? styles.dsActive : ''}`}>
|
||||
<span className={styles.dsLabel}>{ds.label || ds.tableName}</span>
|
||||
<span className={styles.dsType}>{ds.featureCode}</span>
|
||||
<span className={styles.dsIndex}>{ds.ragIndexEnabled ? '\uD83E\uDDE0' : '\u2014'}</span>
|
||||
</div>
|
||||
))}
|
||||
{(fi.dataSources || []).length === 0 && fi.fileCount === 0 && (
|
||||
<div className={styles.dsEmpty}>{t('Keine Datenquellen konfiguriert')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{(inventory.connections || []).length === 0 && (inventory.featureInstances || []).length === 0 && (
|
||||
<div className={styles.emptyState}>{t('Keine Daten für diese Sicht vorhanden.')}</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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) });
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
import React, { useRef, useEffect, useCallback, useState } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { FaRegCopy, FaCheck } from 'react-icons/fa';
|
||||
import api from '../../../api';
|
||||
import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
|
||||
import type { Message, MessageDocument } from '../../../components/UiComponents/Messages/MessagesTypes';
|
||||
|
|
@ -41,6 +42,14 @@ export const ChatStream: React.FC<ChatStreamProps> = ({ messages,
|
|||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const audioQueue = useAudioQueue();
|
||||
const enqueuedIdsRef = useRef<Set<string>>(new Set());
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
|
||||
const _handleCopy = useCallback((msgId: string, text: string) => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopiedId(msgId);
|
||||
setTimeout(() => setCopiedId((prev) => (prev === msgId ? null : prev)), 1500);
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
|
|
@ -92,7 +101,25 @@ export const ChatStream: React.FC<ChatStreamProps> = ({ messages,
|
|||
}}
|
||||
>
|
||||
{msg.role === 'assistant' && (
|
||||
<div style={{ fontSize: 11, color: '#888', marginBottom: 4 }}>Assistant</div>
|
||||
<div style={{ fontSize: 11, color: '#888', marginBottom: 4, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span>Assistant</span>
|
||||
{msg.message && (
|
||||
<button
|
||||
onClick={() => _handleCopy(msg.id, msg.message!)}
|
||||
title={copiedId === msg.id ? t('Kopiert') : t('In Zwischenablage kopieren')}
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
padding: '2px 4px', borderRadius: 4, display: 'flex', alignItems: 'center',
|
||||
color: copiedId === msg.id ? 'var(--success-color, #4caf50)' : '#aaa',
|
||||
transition: 'color 0.2s',
|
||||
}}
|
||||
onMouseEnter={e => { if (copiedId !== msg.id) e.currentTarget.style.color = '#666'; }}
|
||||
onMouseLeave={e => { if (copiedId !== msg.id) e.currentTarget.style.color = '#aaa'; }}
|
||||
>
|
||||
{copiedId === msg.id ? <FaCheck size={12} /> : <FaRegCopy size={12} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{msg.role === 'status' ? (
|
||||
<span>{msg.message}</span>
|
||||
|
|
@ -648,6 +675,15 @@ function _CodeBlock({
|
|||
}: React.HTMLAttributes<HTMLElement> & { inline?: boolean }) {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const isInline = !match && !String(children).includes('\n');
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const _copyCode = useCallback(() => {
|
||||
const text = String(children).replace(/\n$/, '');
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}).catch(() => {});
|
||||
}, [children]);
|
||||
|
||||
if (isInline) {
|
||||
return (
|
||||
|
|
@ -668,15 +704,32 @@ function _CodeBlock({
|
|||
|
||||
return (
|
||||
<div style={{ position: 'relative', margin: '8px 0' }}>
|
||||
{match && (
|
||||
<div style={{
|
||||
position: 'absolute', top: 0, right: 0,
|
||||
padding: '2px 8px', fontSize: 10, color: '#888',
|
||||
background: '#2d2d2d', borderBottomLeftRadius: 4,
|
||||
}}>
|
||||
{match[1]}
|
||||
</div>
|
||||
)}
|
||||
<div style={{
|
||||
position: 'absolute', top: 0, right: 0,
|
||||
display: 'flex', alignItems: 'center', gap: 4,
|
||||
background: '#2d2d2d', borderBottomLeftRadius: 4,
|
||||
padding: '2px 4px',
|
||||
}}>
|
||||
{match && (
|
||||
<span style={{ fontSize: 10, color: '#888', padding: '0 4px' }}>
|
||||
{match[1]}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={_copyCode}
|
||||
title={copied ? 'Kopiert' : 'Kopieren'}
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
padding: '2px 6px', display: 'flex', alignItems: 'center',
|
||||
color: copied ? '#4caf50' : '#888',
|
||||
transition: 'color 0.2s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!copied) e.currentTarget.style.color = '#ccc'; }}
|
||||
onMouseLeave={e => { if (!copied) e.currentTarget.style.color = '#888'; }}
|
||||
>
|
||||
{copied ? <FaCheck size={11} /> : <FaRegCopy size={11} />}
|
||||
</button>
|
||||
</div>
|
||||
<pre style={{
|
||||
background: '#1e1e1e',
|
||||
color: '#d4d4d4',
|
||||
|
|
|
|||
|
|
@ -202,6 +202,19 @@ export const WorkspaceInput = forwardRef<WorkspaceInputHandle, WorkspaceInputPro
|
|||
onPendingAttachFdsConsumed?.();
|
||||
}, [pendingAttachFdsId, onPendingAttachFdsConsumed, _persistAttachments, attachedDataSourceIds]);
|
||||
|
||||
const _prevWorkflowId = useRef<string | null | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (_prevWorkflowId.current === undefined) {
|
||||
_prevWorkflowId.current = workflowId ?? null;
|
||||
return;
|
||||
}
|
||||
const wasNull = !_prevWorkflowId.current;
|
||||
_prevWorkflowId.current = workflowId ?? null;
|
||||
if (wasNull && workflowId && (attachedDataSourceIds.length > 0 || attachedFeatureDataSourceIds.length > 0)) {
|
||||
_persistAttachments(attachedDataSourceIds, attachedFeatureDataSourceIds);
|
||||
}
|
||||
}, [workflowId, _persistAttachments, attachedDataSourceIds, attachedFeatureDataSourceIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loadedNonce === undefined) return;
|
||||
setAttachments([]);
|
||||
|
|
@ -534,7 +547,6 @@ export const WorkspaceInput = forwardRef<WorkspaceInputHandle, WorkspaceInputPro
|
|||
const chatId = e.dataTransfer.getData('application/chat-id');
|
||||
if (chatId) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const chatLabel = e.dataTransfer.getData('text/plain');
|
||||
const refLabel = chatLabel ? `[Chat: ${chatLabel}]` : `[Chat: ${chatId.slice(0, 8)}]`;
|
||||
setPrompt(prev => (prev ? `${prev} ${refLabel}` : refLabel));
|
||||
|
|
@ -544,7 +556,6 @@ export const WorkspaceInput = forwardRef<WorkspaceInputHandle, WorkspaceInputPro
|
|||
const featureSourceJson = e.dataTransfer.getData('application/feature-source');
|
||||
if (featureSourceJson && onFeatureSourceDrop) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const params = JSON.parse(featureSourceJson);
|
||||
onFeatureSourceDrop(params);
|
||||
return;
|
||||
|
|
@ -553,7 +564,6 @@ export const WorkspaceInput = forwardRef<WorkspaceInputHandle, WorkspaceInputPro
|
|||
const dataSourceJson = e.dataTransfer.getData('application/datasource');
|
||||
if (dataSourceJson && onDataSourceDrop) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const params = JSON.parse(dataSourceJson);
|
||||
onDataSourceDrop(params);
|
||||
return;
|
||||
|
|
@ -562,7 +572,6 @@ export const WorkspaceInput = forwardRef<WorkspaceInputHandle, WorkspaceInputPro
|
|||
const handled = await _ingestDataTransfer(e.dataTransfer);
|
||||
if (handled) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
}, [_ingestDataTransfer, onFeatureSourceDrop, onDataSourceDrop]);
|
||||
|
|
|
|||
|
|
@ -209,12 +209,15 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
|||
}, [_isCenterDropInteresting]);
|
||||
|
||||
const _handleDrop = useCallback(async (e: React.DragEvent) => {
|
||||
const alreadyHandled = e.defaultPrevented;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounterRef.current = 0;
|
||||
setIsDragOver(false);
|
||||
|
||||
await _consumeDataTransferFilesOrChat(e.dataTransfer);
|
||||
if (!alreadyHandled) {
|
||||
await _consumeDataTransferFilesOrChat(e.dataTransfer);
|
||||
}
|
||||
}, [_consumeDataTransferFilesOrChat]);
|
||||
|
||||
const _handleFileInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
{ "path": "./tsconfig.node.json" },
|
||||
{ "path": "./tsconfig.test.json" }
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue