Fix: A dataset-level tree can also view file-level data. (#18123)

This commit is contained in:
balibabu
2026-08-11 20:28:44 +08:00
committed by GitHub
parent 0cfd8f41e4
commit 1d81ca27cc
6 changed files with 405 additions and 107 deletions

View File

@@ -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>
);

View File

@@ -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 };
}

View File

@@ -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,
};

View File

@@ -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}
/>

View File

@@ -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>

View File

@@ -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;