mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-12 03:33:33 +08:00
Fix: A dataset-level tree can also view file-level data. (#18123)
This commit is contained in:
@@ -27,6 +27,8 @@ export interface TreeDataItem {
|
||||
hasChildren?: boolean;
|
||||
actions?: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
/** Fires when the chevron expands a collapsed branch (split-click mode). */
|
||||
onExpand?: () => void;
|
||||
}
|
||||
|
||||
type TreeProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
@@ -34,6 +36,12 @@ type TreeProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
initialSelectedItemId?: string;
|
||||
onSelectChange?: (item: TreeDataItem | undefined) => void;
|
||||
expandAll?: boolean;
|
||||
/**
|
||||
* Defaults to true: clicking anywhere on a branch row toggles expansion.
|
||||
* When false, only the chevron toggles expansion (firing `onExpand`) and
|
||||
* the rest of the row just selects the item like a leaf (firing `onClick`).
|
||||
*/
|
||||
expandOnRowClick?: boolean;
|
||||
defaultNodeIcon?: any;
|
||||
defaultLeafIcon?: any;
|
||||
};
|
||||
@@ -77,6 +85,7 @@ const TreeView = React.forwardRef<HTMLDivElement, TreeProps>(
|
||||
initialSelectedItemId,
|
||||
onSelectChange,
|
||||
expandAll,
|
||||
expandOnRowClick = true,
|
||||
defaultLeafIcon,
|
||||
defaultNodeIcon,
|
||||
className,
|
||||
@@ -136,6 +145,7 @@ const TreeView = React.forwardRef<HTMLDivElement, TreeProps>(
|
||||
selectedItemId={selectedItemId}
|
||||
handleSelectChange={handleSelectChange}
|
||||
expandedItemIds={expandedItemIds}
|
||||
expandOnRowClick={expandOnRowClick}
|
||||
defaultLeafIcon={defaultLeafIcon}
|
||||
defaultNodeIcon={defaultNodeIcon}
|
||||
{...props}
|
||||
@@ -162,6 +172,7 @@ const TreeItem = React.forwardRef<HTMLDivElement, TreeItemProps>(
|
||||
selectedItemId,
|
||||
handleSelectChange,
|
||||
expandedItemIds,
|
||||
expandOnRowClick = true,
|
||||
defaultNodeIcon,
|
||||
defaultLeafIcon,
|
||||
...props
|
||||
@@ -183,6 +194,7 @@ const TreeItem = React.forwardRef<HTMLDivElement, TreeItemProps>(
|
||||
selectedItemId={selectedItemId}
|
||||
expandedItemIds={expandedItemIds}
|
||||
handleSelectChange={handleSelectChange}
|
||||
expandOnRowClick={expandOnRowClick}
|
||||
defaultNodeIcon={defaultNodeIcon}
|
||||
defaultLeafIcon={defaultLeafIcon}
|
||||
/>
|
||||
@@ -210,6 +222,7 @@ const TreeNode = ({
|
||||
selectedItemId,
|
||||
defaultNodeIcon,
|
||||
defaultLeafIcon,
|
||||
expandOnRowClick = true,
|
||||
}: {
|
||||
item: TreeDataItem;
|
||||
handleSelectChange: (item: TreeDataItem | undefined) => void;
|
||||
@@ -217,10 +230,81 @@ const TreeNode = ({
|
||||
selectedItemId?: string;
|
||||
defaultNodeIcon?: any;
|
||||
defaultLeafIcon?: any;
|
||||
expandOnRowClick?: boolean;
|
||||
}) => {
|
||||
const [value, setValue] = React.useState(
|
||||
expandedItemIds.includes(item.id) ? [item.id] : [],
|
||||
);
|
||||
const isOpen = value.includes(item.id);
|
||||
const isSelected = selectedItemId === item.id;
|
||||
|
||||
const handleSelect = () => {
|
||||
handleSelectChange(item);
|
||||
item.onClick?.();
|
||||
};
|
||||
|
||||
// Split-click mode: the chevron toggles expansion by itself, so its click
|
||||
// must not bubble up to the row (which would also select the node).
|
||||
const handleChevronClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!isOpen) {
|
||||
item.onExpand?.();
|
||||
}
|
||||
};
|
||||
|
||||
const content = (
|
||||
<AccordionContent className="ml-4 pl-1 border-l">
|
||||
<TreeItem
|
||||
data={item.children ?? []}
|
||||
selectedItemId={selectedItemId}
|
||||
handleSelectChange={handleSelectChange}
|
||||
expandedItemIds={expandedItemIds}
|
||||
expandOnRowClick={expandOnRowClick}
|
||||
defaultLeafIcon={defaultLeafIcon}
|
||||
defaultNodeIcon={defaultNodeIcon}
|
||||
/>
|
||||
</AccordionContent>
|
||||
);
|
||||
|
||||
if (!expandOnRowClick) {
|
||||
return (
|
||||
<AccordionPrimitive.Root
|
||||
type="multiple"
|
||||
value={value}
|
||||
onValueChange={setValue}
|
||||
>
|
||||
<AccordionPrimitive.Item value={item.id}>
|
||||
<AccordionPrimitive.Header>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-1 w-full items-center py-2 cursor-pointer',
|
||||
treeVariants(),
|
||||
isSelected && selectedTreeVariants(),
|
||||
)}
|
||||
onClick={handleSelect}
|
||||
>
|
||||
<AccordionPrimitive.Trigger
|
||||
className="mr-1 shrink-0 data-[state=open]:[&>svg]:rotate-90"
|
||||
onClick={handleChevronClick}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4 transition-transform duration-200 text-accent-foreground/50" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
<TreeIcon
|
||||
item={item}
|
||||
isSelected={isSelected}
|
||||
isOpen={isOpen}
|
||||
default={defaultNodeIcon}
|
||||
/>
|
||||
<TreeItemLabel item={item} />
|
||||
<TreeActions isSelected={isSelected}>{item.actions}</TreeActions>
|
||||
</div>
|
||||
</AccordionPrimitive.Header>
|
||||
{content}
|
||||
</AccordionPrimitive.Item>
|
||||
</AccordionPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AccordionPrimitive.Root
|
||||
type="multiple"
|
||||
@@ -229,36 +313,19 @@ const TreeNode = ({
|
||||
>
|
||||
<AccordionPrimitive.Item value={item.id}>
|
||||
<AccordionTrigger
|
||||
className={cn(
|
||||
treeVariants(),
|
||||
selectedItemId === item.id && selectedTreeVariants(),
|
||||
)}
|
||||
onClick={() => {
|
||||
handleSelectChange(item);
|
||||
item.onClick?.();
|
||||
}}
|
||||
className={cn(treeVariants(), isSelected && selectedTreeVariants())}
|
||||
onClick={handleSelect}
|
||||
>
|
||||
<TreeIcon
|
||||
item={item}
|
||||
isSelected={selectedItemId === item.id}
|
||||
isOpen={value.includes(item.id)}
|
||||
isSelected={isSelected}
|
||||
isOpen={isOpen}
|
||||
default={defaultNodeIcon}
|
||||
/>
|
||||
<TreeItemLabel item={item} />
|
||||
<TreeActions isSelected={selectedItemId === item.id}>
|
||||
{item.actions}
|
||||
</TreeActions>
|
||||
<TreeActions isSelected={isSelected}>{item.actions}</TreeActions>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="ml-4 pl-1 border-l">
|
||||
<TreeItem
|
||||
data={item.children ?? []}
|
||||
selectedItemId={selectedItemId}
|
||||
handleSelectChange={handleSelectChange}
|
||||
expandedItemIds={expandedItemIds}
|
||||
defaultLeafIcon={defaultLeafIcon}
|
||||
defaultNodeIcon={defaultNodeIcon}
|
||||
/>
|
||||
</AccordionContent>
|
||||
{content}
|
||||
</AccordionPrimitive.Item>
|
||||
</AccordionPrimitive.Root>
|
||||
);
|
||||
|
||||
@@ -755,34 +755,50 @@ export const useFetchDocumentThumbnailsByIds = () => {
|
||||
return { data, setDocumentIds };
|
||||
};
|
||||
|
||||
export function useFetchDocumentStructureGraph(keywords?: string) {
|
||||
const { knowledgeId: datasetId, documentId } = useGetKnowledgeSearchParams();
|
||||
export function useFetchDocumentStructureGraphById(
|
||||
datasetId: string,
|
||||
documentId: string,
|
||||
keywords?: string,
|
||||
) {
|
||||
const enabled = !!datasetId && !!documentId;
|
||||
const trimmedKeywords = keywords?.trim();
|
||||
|
||||
const { data, isFetching: loading } =
|
||||
useQuery<IStructureGraphResponse | null>({
|
||||
queryKey: trimmedKeywords
|
||||
? DocumentStructureKeys.graphWithKeywords(
|
||||
datasetId,
|
||||
documentId,
|
||||
trimmedKeywords,
|
||||
)
|
||||
: DocumentStructureKeys.graph(datasetId, documentId),
|
||||
enabled,
|
||||
initialData: null,
|
||||
gcTime: 0,
|
||||
placeholderData: keepPreviousData,
|
||||
queryFn: async () => {
|
||||
const { data } =
|
||||
await documentStructureService.getDocumentStructureGraph(
|
||||
datasetId,
|
||||
documentId,
|
||||
trimmedKeywords,
|
||||
);
|
||||
return data?.data ?? null;
|
||||
},
|
||||
});
|
||||
const {
|
||||
data,
|
||||
isFetching: loading,
|
||||
isPlaceholderData,
|
||||
} = useQuery<IStructureGraphResponse | null>({
|
||||
queryKey: trimmedKeywords
|
||||
? DocumentStructureKeys.graphWithKeywords(
|
||||
datasetId,
|
||||
documentId,
|
||||
trimmedKeywords,
|
||||
)
|
||||
: DocumentStructureKeys.graph(datasetId, documentId),
|
||||
enabled,
|
||||
initialData: null,
|
||||
gcTime: 0,
|
||||
placeholderData: keepPreviousData,
|
||||
queryFn: async () => {
|
||||
const { data } = await documentStructureService.getDocumentStructureGraph(
|
||||
datasetId,
|
||||
documentId,
|
||||
trimmedKeywords,
|
||||
);
|
||||
return data?.data ?? null;
|
||||
},
|
||||
});
|
||||
|
||||
return { data, loading, isPlaceholderData };
|
||||
}
|
||||
|
||||
export function useFetchDocumentStructureGraph(keywords?: string) {
|
||||
const { knowledgeId: datasetId, documentId } = useGetKnowledgeSearchParams();
|
||||
const { data, loading } = useFetchDocumentStructureGraphById(
|
||||
datasetId,
|
||||
documentId,
|
||||
keywords,
|
||||
);
|
||||
|
||||
return { data, loading };
|
||||
}
|
||||
|
||||
@@ -4,20 +4,25 @@ import {
|
||||
useFetchDatasetNav,
|
||||
useFetchDatasetNavChildren,
|
||||
} from '@/hooks/use-dataset-nav-request';
|
||||
import { useFetchDocumentStructureGraphById } from '@/hooks/use-document-request';
|
||||
import { useKnowledgeBaseId } from '@/hooks/use-knowledge-request';
|
||||
import { DatasetNavNode } from '@/interfaces/database/dataset-nav';
|
||||
import { IStructureGraphTemplate } from '@/interfaces/database/document-structure';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
export interface SelectedNavNode {
|
||||
parentName: string;
|
||||
parentName: string | null;
|
||||
name: string;
|
||||
description: string;
|
||||
doc_count: number;
|
||||
docId?: string;
|
||||
doc_count?: number;
|
||||
keywords?: string[];
|
||||
entities?: string[];
|
||||
graph_content?: string;
|
||||
}
|
||||
|
||||
export function useCompilationNav() {
|
||||
const kbId = useKnowledgeBaseId();
|
||||
const { data: navList, loading: navLoading } = useFetchDatasetNav();
|
||||
const { deleteNav, loading: deleteNavLoading } = useDeleteDatasetNav();
|
||||
const { deleteNavNode, loading: deleteNodeLoading } =
|
||||
@@ -27,11 +32,17 @@ export function useCompilationNav() {
|
||||
const [childrenMap, setChildrenMap] = useState<
|
||||
Record<string, DatasetNavNode[]>
|
||||
>({});
|
||||
const [loadingDocId, setLoadingDocId] = useState<string | null>(null);
|
||||
const [structureMap, setStructureMap] = useState<
|
||||
Record<string, IStructureGraphTemplate[]>
|
||||
>({});
|
||||
const [selectedNode, setSelectedNode] = useState<SelectedNavNode | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const { data: childrenData } = useFetchDatasetNavChildren(loadingParent);
|
||||
const { data: structureData, isPlaceholderData: structurePlaceholder } =
|
||||
useFetchDocumentStructureGraphById(kbId, loadingDocId ?? '');
|
||||
|
||||
useEffect(() => {
|
||||
if (loadingParent && childrenData) {
|
||||
@@ -42,6 +53,17 @@ export function useCompilationNav() {
|
||||
}
|
||||
}, [loadingParent, childrenData]);
|
||||
|
||||
useEffect(() => {
|
||||
// keepPreviousData serves the previous document's graph while the new one
|
||||
// loads; only store the response once it belongs to loadingDocId.
|
||||
if (loadingDocId && structureData && !structurePlaceholder) {
|
||||
setStructureMap((prev) => ({
|
||||
...prev,
|
||||
[loadingDocId]: structureData.templates,
|
||||
}));
|
||||
}
|
||||
}, [loadingDocId, structureData, structurePlaceholder]);
|
||||
|
||||
const loadChildren = useCallback(
|
||||
(name: string) => {
|
||||
if (!(name in childrenMap)) {
|
||||
@@ -51,6 +73,15 @@ export function useCompilationNav() {
|
||||
[childrenMap],
|
||||
);
|
||||
|
||||
const loadStructure = useCallback(
|
||||
(docId: string) => {
|
||||
if (!(docId in structureMap)) {
|
||||
setLoadingDocId(docId);
|
||||
}
|
||||
},
|
||||
[structureMap],
|
||||
);
|
||||
|
||||
const removeChild = useCallback((parentName: string, childName: string) => {
|
||||
setChildrenMap((prev) => {
|
||||
const children = prev[parentName];
|
||||
@@ -75,24 +106,29 @@ export function useCompilationNav() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const dropStructure = useCallback((docId: string) => {
|
||||
setStructureMap((prev) => {
|
||||
if (!(docId in prev)) {
|
||||
return prev;
|
||||
}
|
||||
const next = { ...prev };
|
||||
delete next[docId];
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const resetNav = useCallback(() => {
|
||||
setSelectedNode(null);
|
||||
setChildrenMap({});
|
||||
setLoadingParent(null);
|
||||
setStructureMap({});
|
||||
setLoadingDocId(null);
|
||||
}, []);
|
||||
|
||||
const handleParentClick = useCallback(
|
||||
(node: DatasetNavNode) => {
|
||||
setSelectedNode(null);
|
||||
if (node.has_children) {
|
||||
loadChildren(node.name);
|
||||
}
|
||||
},
|
||||
[loadChildren],
|
||||
);
|
||||
|
||||
const handleChildClick = useCallback(
|
||||
(node: DatasetNavNode, parentName: string) => {
|
||||
// Row click selects the node and shows its description on the right,
|
||||
// same as a leaf; expansion is handled separately via handleNodeExpand.
|
||||
const handleNodeClick = useCallback(
|
||||
(node: DatasetNavNode, parentName: string | null) => {
|
||||
setSelectedNode({
|
||||
parentName,
|
||||
name: node.name,
|
||||
@@ -106,6 +142,29 @@ export function useCompilationNav() {
|
||||
[],
|
||||
);
|
||||
|
||||
const handleNodeExpand = useCallback(
|
||||
(node: DatasetNavNode) => {
|
||||
if (node.has_children) {
|
||||
loadChildren(node.name);
|
||||
} else if (node.doc_id) {
|
||||
loadStructure(node.doc_id);
|
||||
}
|
||||
},
|
||||
[loadChildren, loadStructure],
|
||||
);
|
||||
|
||||
const handleEntityClick = useCallback(
|
||||
(docNode: DatasetNavNode, name: string, description: string) => {
|
||||
setSelectedNode({
|
||||
parentName: docNode.name,
|
||||
name,
|
||||
description,
|
||||
docId: docNode.doc_id,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDeleteAll = useCallback(async () => {
|
||||
const data = await deleteNav();
|
||||
if (data?.code === 0) {
|
||||
@@ -115,37 +174,54 @@ export function useCompilationNav() {
|
||||
|
||||
const handleDeleteNode = useCallback(
|
||||
async (name: string, parentName: string | null) => {
|
||||
const removed = parentName
|
||||
? childrenMap[parentName]?.find((node) => node.name === name)
|
||||
: undefined;
|
||||
const data = await deleteNavNode(name);
|
||||
if (data?.code === 0) {
|
||||
if (parentName) {
|
||||
// The children query of this parent may have no active observer, so
|
||||
// invalidation alone would not refetch — filter the local map too.
|
||||
removeChild(parentName, name);
|
||||
// A deleted sub-cluster may have loaded children; a deleted document
|
||||
// may have a loaded structure graph. Drop both from the local maps.
|
||||
dropChildren(name);
|
||||
if (removed?.doc_id) {
|
||||
dropStructure(removed.doc_id);
|
||||
}
|
||||
setSelectedNode((current) =>
|
||||
current?.parentName === parentName && current?.name === name
|
||||
(current?.parentName === parentName && current?.name === name) ||
|
||||
(removed?.doc_id && current?.docId === removed.doc_id)
|
||||
? null
|
||||
: current,
|
||||
);
|
||||
} else {
|
||||
dropChildren(name);
|
||||
// Clear the selection when the deleted root is the selected node
|
||||
// itself or the parent of the selected child.
|
||||
setSelectedNode((current) =>
|
||||
current?.parentName === name ? null : current,
|
||||
current?.parentName === name ||
|
||||
(current?.parentName === null && current?.name === name)
|
||||
? null
|
||||
: current,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[deleteNavNode, removeChild, dropChildren],
|
||||
[deleteNavNode, childrenMap, removeChild, dropChildren, dropStructure],
|
||||
);
|
||||
|
||||
return {
|
||||
navList,
|
||||
navLoading,
|
||||
childrenMap,
|
||||
structureMap,
|
||||
selectedNode,
|
||||
deleteNavLoading,
|
||||
deleteNodeLoading,
|
||||
handleParentClick,
|
||||
handleChildClick,
|
||||
handleNodeClick,
|
||||
handleNodeExpand,
|
||||
handleEntityClick,
|
||||
handleDeleteAll,
|
||||
handleDeleteNode,
|
||||
};
|
||||
|
||||
@@ -6,10 +6,11 @@ import {
|
||||
DatasetNavList,
|
||||
DatasetNavNode,
|
||||
} from '@/interfaces/database/dataset-nav';
|
||||
import { IStructureGraphTemplate } from '@/interfaces/database/document-structure';
|
||||
import { FileText, Folder, Trash2 } from 'lucide-react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { buildNavTreeData } from './utils/nav-tree';
|
||||
import { buildNavTreeData, NavEntityClickHandler } from './utils/nav-tree';
|
||||
|
||||
type NavNodeDeleteActionProps = {
|
||||
name: string;
|
||||
@@ -65,10 +66,12 @@ type NavTreeLeftPanelProps = {
|
||||
navList: DatasetNavList | null;
|
||||
navLoading: boolean;
|
||||
childrenMap: Record<string, DatasetNavNode[]>;
|
||||
structureMap: Record<string, IStructureGraphTemplate[]>;
|
||||
deleteNavLoading: boolean;
|
||||
deleteNodeLoading: boolean;
|
||||
onParentClick: (node: DatasetNavNode) => void;
|
||||
onChildClick: (node: DatasetNavNode, parentName: string) => void;
|
||||
onNodeClick: (node: DatasetNavNode, parentName: string | null) => void;
|
||||
onNodeExpand: (node: DatasetNavNode) => void;
|
||||
onEntityClick: NavEntityClickHandler;
|
||||
onDeleteAll: () => void;
|
||||
onDeleteNode: (name: string, parentName: string | null) => void;
|
||||
};
|
||||
@@ -77,10 +80,12 @@ export function NavTreeLeftPanel({
|
||||
navList,
|
||||
navLoading,
|
||||
childrenMap,
|
||||
structureMap,
|
||||
deleteNavLoading,
|
||||
deleteNodeLoading,
|
||||
onParentClick,
|
||||
onChildClick,
|
||||
onNodeClick,
|
||||
onNodeExpand,
|
||||
onEntityClick,
|
||||
onDeleteAll,
|
||||
onDeleteNode,
|
||||
}: NavTreeLeftPanelProps) {
|
||||
@@ -102,17 +107,21 @@ export function NavTreeLeftPanel({
|
||||
() =>
|
||||
buildNavTreeData(navList?.items, {
|
||||
childrenMap,
|
||||
structureMap,
|
||||
getActions: renderNavActions,
|
||||
onParentClick,
|
||||
onChildClick,
|
||||
onNodeClick,
|
||||
onNodeExpand,
|
||||
onEntityClick,
|
||||
loadingPlaceholder: t('datasetNav.loading'),
|
||||
}),
|
||||
[
|
||||
navList?.items,
|
||||
childrenMap,
|
||||
structureMap,
|
||||
renderNavActions,
|
||||
onParentClick,
|
||||
onChildClick,
|
||||
onNodeClick,
|
||||
onNodeExpand,
|
||||
onEntityClick,
|
||||
t,
|
||||
],
|
||||
);
|
||||
@@ -153,6 +162,7 @@ export function NavTreeLeftPanel({
|
||||
) : (
|
||||
<TreeView
|
||||
data={treeData}
|
||||
expandOnRowClick={false}
|
||||
defaultNodeIcon={Folder}
|
||||
defaultLeafIcon={FileText}
|
||||
/>
|
||||
|
||||
@@ -15,11 +15,13 @@ export function NavTreeView() {
|
||||
navList,
|
||||
navLoading,
|
||||
childrenMap,
|
||||
structureMap,
|
||||
selectedNode,
|
||||
deleteNavLoading,
|
||||
deleteNodeLoading,
|
||||
handleParentClick,
|
||||
handleChildClick,
|
||||
handleNodeClick,
|
||||
handleNodeExpand,
|
||||
handleEntityClick,
|
||||
handleDeleteAll,
|
||||
handleDeleteNode,
|
||||
} = useCompilationNav();
|
||||
@@ -32,10 +34,12 @@ export function NavTreeView() {
|
||||
navList={navList}
|
||||
navLoading={navLoading}
|
||||
childrenMap={childrenMap}
|
||||
structureMap={structureMap}
|
||||
deleteNavLoading={deleteNavLoading}
|
||||
deleteNodeLoading={deleteNodeLoading}
|
||||
onParentClick={handleParentClick}
|
||||
onChildClick={handleChildClick}
|
||||
onNodeClick={handleNodeClick}
|
||||
onNodeExpand={handleNodeExpand}
|
||||
onEntityClick={handleEntityClick}
|
||||
onDeleteAll={handleDeleteAll}
|
||||
onDeleteNode={handleDeleteNode}
|
||||
/>
|
||||
@@ -48,9 +52,13 @@ export function NavTreeView() {
|
||||
<h3 className="text-sm font-medium text-text-primary">
|
||||
{selectedNode.name}
|
||||
</h3>
|
||||
<span className="text-xs text-text-secondary">
|
||||
{t('datasetNav.docCount', { count: selectedNode.doc_count })}
|
||||
</span>
|
||||
{selectedNode.doc_count !== undefined && (
|
||||
<span className="text-xs text-text-secondary">
|
||||
{t('datasetNav.docCount', {
|
||||
count: selectedNode.doc_count,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</header>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-3 text-sm text-text-primary space-y-4">
|
||||
<div>
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import {
|
||||
adaptPageIndexToTreeData,
|
||||
adaptTreeToTreeData,
|
||||
getEntityDisplayName,
|
||||
} from '@/components/structure-graph/adapters';
|
||||
import { TreeDataItem } from '@/components/ui/tree-view';
|
||||
import { CompilationTemplateKind } from '@/constants/compilation';
|
||||
import { DatasetNavNode } from '@/interfaces/database/dataset-nav';
|
||||
import {
|
||||
IStructureGraphEntity,
|
||||
IStructureGraphTemplate,
|
||||
} from '@/interfaces/database/document-structure';
|
||||
import trim from 'lodash/trim';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
@@ -8,51 +18,162 @@ export type NavTreeActionsFactory = (
|
||||
parentName: string | null,
|
||||
) => ReactNode;
|
||||
|
||||
export type NavEntityClickHandler = (
|
||||
docNode: DatasetNavNode,
|
||||
name: string,
|
||||
description: string,
|
||||
) => void;
|
||||
|
||||
type BuildNavTreeDataOptions = {
|
||||
childrenMap: Record<string, DatasetNavNode[]>;
|
||||
structureMap: Record<string, IStructureGraphTemplate[]>;
|
||||
getActions?: NavTreeActionsFactory;
|
||||
onParentClick: (node: DatasetNavNode) => void;
|
||||
onChildClick: (node: DatasetNavNode, parentName: string) => void;
|
||||
onNodeClick: (node: DatasetNavNode, parentName: string | null) => void;
|
||||
onNodeExpand: (node: DatasetNavNode) => void;
|
||||
onEntityClick?: NavEntityClickHandler;
|
||||
loadingPlaceholder: string;
|
||||
};
|
||||
|
||||
function getEntityDescription(entity: IStructureGraphEntity): string {
|
||||
return entity.description ?? entity.discription ?? '';
|
||||
}
|
||||
|
||||
// Kinds without a tree-shaped relation adapter fall back to a flat list.
|
||||
function buildFlatEntityItems(
|
||||
entities: IStructureGraphEntity[],
|
||||
): TreeDataItem[] {
|
||||
return entities
|
||||
.map((entity) => ({
|
||||
id: entity.id ?? entity.name ?? '',
|
||||
name: getEntityDisplayName(entity),
|
||||
entityType: entity.type,
|
||||
}))
|
||||
.filter((item) => item.id);
|
||||
}
|
||||
|
||||
function buildTemplateChildren(
|
||||
template: IStructureGraphTemplate,
|
||||
): TreeDataItem[] {
|
||||
switch (template.kind) {
|
||||
case CompilationTemplateKind.PageIndex:
|
||||
return adaptPageIndexToTreeData(template);
|
||||
case CompilationTemplateKind.Tree:
|
||||
case 'raptor':
|
||||
return adaptTreeToTreeData(template);
|
||||
default:
|
||||
return buildFlatEntityItems(template.entities);
|
||||
}
|
||||
}
|
||||
|
||||
// Adapter output uses raw entity ids; re-id under the document node's tree id
|
||||
// so ids stay unique across the whole nav tree, and wire entity clicks.
|
||||
function decorateStructureItems(
|
||||
items: TreeDataItem[],
|
||||
entityById: Map<string, IStructureGraphEntity>,
|
||||
idPrefix: string,
|
||||
onEntityClick?: (entity: IStructureGraphEntity) => void,
|
||||
): TreeDataItem[] {
|
||||
return items.map((item) => {
|
||||
const entity = entityById.get(item.id);
|
||||
return {
|
||||
...item,
|
||||
id: `${idPrefix}/${item.id}`,
|
||||
onClick:
|
||||
entity && onEntityClick ? () => onEntityClick(entity) : undefined,
|
||||
children: item.children?.length
|
||||
? decorateStructureItems(
|
||||
item.children,
|
||||
entityById,
|
||||
idPrefix,
|
||||
onEntityClick,
|
||||
)
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Entity items mount directly under the document node — the template itself
|
||||
// is not rendered as a tree node, its id only prefixes entity ids so they
|
||||
// stay unique when a document has multiple templates.
|
||||
function buildStructureTreeData(
|
||||
templates: IStructureGraphTemplate[],
|
||||
idPrefix: string,
|
||||
onEntityClick?: (entity: IStructureGraphEntity) => void,
|
||||
): TreeDataItem[] {
|
||||
return templates.flatMap((template) => {
|
||||
const entityById = new Map<string, IStructureGraphEntity>(
|
||||
template.entities
|
||||
.map((entity) => [entity.id ?? entity.name ?? '', entity] as const)
|
||||
.filter(([id]) => !!id),
|
||||
);
|
||||
return decorateStructureItems(
|
||||
buildTemplateChildren(template),
|
||||
entityById,
|
||||
`${idPrefix}/${template.template_id}`,
|
||||
onEntityClick,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function buildNavTreeData(
|
||||
items: DatasetNavNode[] = [],
|
||||
{
|
||||
childrenMap,
|
||||
getActions,
|
||||
onParentClick,
|
||||
onChildClick,
|
||||
loadingPlaceholder,
|
||||
}: BuildNavTreeDataOptions,
|
||||
options: BuildNavTreeDataOptions,
|
||||
parentName: string | null = null,
|
||||
idPrefix = '',
|
||||
): TreeDataItem[] {
|
||||
const {
|
||||
childrenMap,
|
||||
structureMap,
|
||||
getActions,
|
||||
onNodeClick,
|
||||
onNodeExpand,
|
||||
onEntityClick,
|
||||
loadingPlaceholder,
|
||||
} = options;
|
||||
|
||||
return items.map((node) => {
|
||||
const id = idPrefix ? `${idPrefix}/${node.name}` : node.name;
|
||||
const item: TreeDataItem = {
|
||||
id: node.name,
|
||||
id,
|
||||
name: trim(node.name),
|
||||
hasChildren: node.has_children,
|
||||
actions: getActions?.(node, null),
|
||||
onClick: () => onParentClick(node),
|
||||
actions: getActions?.(node, parentName),
|
||||
onClick: () => onNodeClick(node, parentName),
|
||||
onExpand: () => onNodeExpand(node),
|
||||
};
|
||||
|
||||
if (node.has_children) {
|
||||
item.hasChildren = true;
|
||||
const children = childrenMap[node.name];
|
||||
if (children?.length) {
|
||||
item.children = children.map((child) => ({
|
||||
id: `${node.name}/${child.name}`,
|
||||
name: trim(child.name),
|
||||
hasChildren: child.has_children,
|
||||
actions: getActions?.(child, node.name),
|
||||
onClick: () => onChildClick(child, node.name),
|
||||
}));
|
||||
item.children = buildNavTreeData(children, options, node.name, id);
|
||||
} else if (!children) {
|
||||
// Children not fetched yet: a placeholder keeps the node rendered as
|
||||
// an expandable branch until the request resolves.
|
||||
item.children = [
|
||||
{ id: `${node.name}/__loading__`, name: loadingPlaceholder },
|
||||
];
|
||||
item.children = [{ id: `${id}/__loading__`, name: loadingPlaceholder }];
|
||||
}
|
||||
// Fetched but empty: leave children unset so the branch opens to nothing.
|
||||
} else if (node.doc_id) {
|
||||
// Document leaf: expandable into its structure graph entities.
|
||||
const templates = structureMap[node.doc_id];
|
||||
if (!templates) {
|
||||
// Not fetched yet: a placeholder keeps the node rendered as an
|
||||
// expandable branch until the request resolves.
|
||||
item.hasChildren = true;
|
||||
item.children = [{ id: `${id}/__loading__`, name: loadingPlaceholder }];
|
||||
} else {
|
||||
const children = buildStructureTreeData(templates, id, (entity) =>
|
||||
onEntityClick?.(
|
||||
node,
|
||||
getEntityDisplayName(entity),
|
||||
getEntityDescription(entity),
|
||||
),
|
||||
);
|
||||
if (children.length) {
|
||||
item.hasChildren = true;
|
||||
item.children = children;
|
||||
}
|
||||
// No entity nodes: leave hasChildren unset so the node stays a leaf.
|
||||
}
|
||||
// Fetched but empty: leave children unset so the node becomes a leaf.
|
||||
}
|
||||
|
||||
return item;
|
||||
|
||||
Reference in New Issue
Block a user