diff --git a/web/src/components/ui/tree-view.tsx b/web/src/components/ui/tree-view.tsx index cb39506add..3610c1029f 100644 --- a/web/src/components/ui/tree-view.tsx +++ b/web/src/components/ui/tree-view.tsx @@ -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 & { @@ -34,6 +36,12 @@ type TreeProps = React.HTMLAttributes & { 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( initialSelectedItemId, onSelectChange, expandAll, + expandOnRowClick = true, defaultLeafIcon, defaultNodeIcon, className, @@ -136,6 +145,7 @@ const TreeView = React.forwardRef( selectedItemId={selectedItemId} handleSelectChange={handleSelectChange} expandedItemIds={expandedItemIds} + expandOnRowClick={expandOnRowClick} defaultLeafIcon={defaultLeafIcon} defaultNodeIcon={defaultNodeIcon} {...props} @@ -162,6 +172,7 @@ const TreeItem = React.forwardRef( selectedItemId, handleSelectChange, expandedItemIds, + expandOnRowClick = true, defaultNodeIcon, defaultLeafIcon, ...props @@ -183,6 +194,7 @@ const TreeItem = React.forwardRef( 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 = ( + + + + ); + + if (!expandOnRowClick) { + return ( + + + +
+ + + + + + {item.actions} +
+
+ {content} +
+
+ ); + } + return ( { - handleSelectChange(item); - item.onClick?.(); - }} + className={cn(treeVariants(), isSelected && selectedTreeVariants())} + onClick={handleSelect} > - - {item.actions} - + {item.actions} - - - + {content} ); diff --git a/web/src/hooks/use-document-request.ts b/web/src/hooks/use-document-request.ts index 1e53351afa..04ad407827 100644 --- a/web/src/hooks/use-document-request.ts +++ b/web/src/hooks/use-document-request.ts @@ -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({ - 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({ + 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 }; } diff --git a/web/src/pages/dataset/compilation/hooks/use-compilation-nav.ts b/web/src/pages/dataset/compilation/hooks/use-compilation-nav.ts index f3739a0f19..e30562bb07 100644 --- a/web/src/pages/dataset/compilation/hooks/use-compilation-nav.ts +++ b/web/src/pages/dataset/compilation/hooks/use-compilation-nav.ts @@ -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 >({}); + const [loadingDocId, setLoadingDocId] = useState(null); + const [structureMap, setStructureMap] = useState< + Record + >({}); const [selectedNode, setSelectedNode] = useState( 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, }; diff --git a/web/src/pages/dataset/compilation/nav-tree-left-panel.tsx b/web/src/pages/dataset/compilation/nav-tree-left-panel.tsx index 2c71f59353..49e9f373e4 100644 --- a/web/src/pages/dataset/compilation/nav-tree-left-panel.tsx +++ b/web/src/pages/dataset/compilation/nav-tree-left-panel.tsx @@ -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; + structureMap: Record; 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({ ) : ( diff --git a/web/src/pages/dataset/compilation/nav-tree-view.tsx b/web/src/pages/dataset/compilation/nav-tree-view.tsx index 0b6414ab17..07bc1c102b 100644 --- a/web/src/pages/dataset/compilation/nav-tree-view.tsx +++ b/web/src/pages/dataset/compilation/nav-tree-view.tsx @@ -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() {

{selectedNode.name}

- - {t('datasetNav.docCount', { count: selectedNode.doc_count })} - + {selectedNode.doc_count !== undefined && ( + + {t('datasetNav.docCount', { + count: selectedNode.doc_count, + })} + + )}
diff --git a/web/src/pages/dataset/compilation/utils/nav-tree.ts b/web/src/pages/dataset/compilation/utils/nav-tree.ts index 7b4e3ded6b..ecd1815ada 100644 --- a/web/src/pages/dataset/compilation/utils/nav-tree.ts +++ b/web/src/pages/dataset/compilation/utils/nav-tree.ts @@ -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; + structureMap: Record; 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, + 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( + 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;