From 069f36213c7503bd8f8477795f7de869cce575ba Mon Sep 17 00:00:00 2001 From: R-Hart80 Date: Mon, 11 May 2026 11:02:56 -0300 Subject: [PATCH 1/2] fix: highlight ancestor nodes whose labels match the search query mergePathsIntoTree now accepts a query string and marks any node isSearchMatch when its label contains the query (case-insensitive), not only the leaf IRIs returned by the backend. Plumbed searchQuery from useTreeSearch through UseFilteredTreeOptions into the merge. Four new tests cover: ancestor highlight, case-insensitivity, empty query no-op, and non-matching ancestor stays unhighlighted. Fixes #209. Co-Authored-By: Claude Sonnet 4.6 --- __tests__/lib/hooks/useFilteredTree.test.ts | 66 +++++++++++++++++++ .../developer/DeveloperEditorLayout.tsx | 1 + .../editor/standard/StandardEditorLayout.tsx | 1 + lib/hooks/useFilteredTree.ts | 14 ++-- 4 files changed, 78 insertions(+), 4 deletions(-) diff --git a/__tests__/lib/hooks/useFilteredTree.test.ts b/__tests__/lib/hooks/useFilteredTree.test.ts index afc55306..5cebdc4c 100644 --- a/__tests__/lib/hooks/useFilteredTree.test.ts +++ b/__tests__/lib/hooks/useFilteredTree.test.ts @@ -188,6 +188,72 @@ describe("mergePathsIntoTree", () => { expect(tree).toHaveLength(0); }); + it("highlights an ancestor whose label matches the query, even though it is not a backend result", () => { + const paths: AncestorPath[] = [ + { + matchIri: "urn:leaf", + matchLabel: "Document Collection Event", + ancestors: [ + { iri: "urn:root", label: "Event", child_count: 1 }, + { iri: "urn:ancestor", label: "Document Collection and Production Events", child_count: 1 }, + ], + }, + ]; + + const tree = mergePathsIntoTree(paths, "document"); + + // Root ("Event") does not match + expect(tree[0].isSearchMatch).toBeFalsy(); + // Ancestor ("Document Collection and Production Events") matches the query + expect(tree[0].children[0].isSearchMatch).toBe(true); + // Leaf ("Document Collection Event") is a backend match AND label matches + expect(tree[0].children[0].children[0].isSearchMatch).toBe(true); + }); + + it("is case-insensitive when matching ancestor labels against the query", () => { + const paths: AncestorPath[] = [ + { + matchIri: "urn:leaf", + matchLabel: "Leaf", + ancestors: [{ iri: "urn:ancestor", label: "Document Archive", child_count: 1 }], + }, + ]; + + const tree = mergePathsIntoTree(paths, "DOCUMENT"); + + expect(tree[0].isSearchMatch).toBe(true); + }); + + it("does not highlight ancestors when query is empty", () => { + const paths: AncestorPath[] = [ + { + matchIri: "urn:leaf", + matchLabel: "Leaf", + ancestors: [{ iri: "urn:ancestor", label: "Some Ancestor", child_count: 1 }], + }, + ]; + + const tree = mergePathsIntoTree(paths, ""); + + expect(tree[0].isSearchMatch).toBeFalsy(); + expect(tree[0].children[0].isSearchMatch).toBe(true); + }); + + it("does not highlight ancestors whose labels do not contain the query", () => { + const paths: AncestorPath[] = [ + { + matchIri: "urn:leaf", + matchLabel: "Document Event", + ancestors: [{ iri: "urn:ancestor", label: "Unrelated Category", child_count: 1 }], + }, + ]; + + const tree = mergePathsIntoTree(paths, "document"); + + expect(tree[0].isSearchMatch).toBeFalsy(); + expect(tree[0].children[0].isSearchMatch).toBe(true); + }); + it("assigns entityType 'class' to all nodes", () => { const paths: AncestorPath[] = [ { diff --git a/components/editor/developer/DeveloperEditorLayout.tsx b/components/editor/developer/DeveloperEditorLayout.tsx index d6638d7b..fce7b083 100644 --- a/components/editor/developer/DeveloperEditorLayout.tsx +++ b/components/editor/developer/DeveloperEditorLayout.tsx @@ -331,6 +331,7 @@ export function DeveloperEditorLayout(props: DeveloperEditorLayoutProps) { projectId, accessToken, branch: activeBranch, + searchQuery, }); const handleSearchSelect = (iri: string) => { diff --git a/components/editor/standard/StandardEditorLayout.tsx b/components/editor/standard/StandardEditorLayout.tsx index 7bdeca78..ed932f32 100644 --- a/components/editor/standard/StandardEditorLayout.tsx +++ b/components/editor/standard/StandardEditorLayout.tsx @@ -296,6 +296,7 @@ export function StandardEditorLayout(props: StandardEditorLayoutProps) { projectId, accessToken, branch: activeBranch, + searchQuery, }); const handleSearchSelect = (iri: string) => { diff --git a/lib/hooks/useFilteredTree.ts b/lib/hooks/useFilteredTree.ts index 25e02af8..49258428 100644 --- a/lib/hooks/useFilteredTree.ts +++ b/lib/hooks/useFilteredTree.ts @@ -11,6 +11,7 @@ interface UseFilteredTreeOptions { projectId: string; accessToken?: string; branch?: string; + searchQuery?: string; } interface UseFilteredTreeReturn { @@ -29,6 +30,7 @@ export function useFilteredTree({ projectId, accessToken, branch, + searchQuery, }: UseFilteredTreeOptions): UseFilteredTreeReturn { const [filteredNodes, setFilteredNodes] = useState(null); const [isBuilding, setIsBuilding] = useState(false); @@ -92,7 +94,7 @@ export function useFilteredTree({ if (buildId !== buildIdRef.current) return; // Merge all ancestor paths into a unified tree - const tree = mergePathsIntoTree(ancestorPaths); + const tree = mergePathsIntoTree(ancestorPaths, searchQuery ?? ""); setFilteredNodes(tree); setFirstMatchIri(limitedResults[0]?.iri ?? null); @@ -121,8 +123,11 @@ export interface AncestorPath { /** * Merge multiple ancestor paths into a unified EntityTreeNode tree. * Matched nodes get `isSearchMatch: true`, all ancestors are `isExpanded: true`. + * If `query` is provided, any node whose label contains the query (case-insensitive) + * is also marked as a match — not just the leaf IRIs the backend returned. */ -export function mergePathsIntoTree(paths: AncestorPath[]): EntityTreeNode[] { +export function mergePathsIntoTree(paths: AncestorPath[], query = ""): EntityTreeNode[] { + const q = query.trim().toLowerCase(); // nodeMap: iri -> EntityTreeNode const nodeMap = new Map(); // childrenMap: parentIri -> Set @@ -143,6 +148,7 @@ export function mergePathsIntoTree(paths: AncestorPath[]): EntityTreeNode[] { for (let i = 0; i < fullPath.length; i++) { const item = fullPath[i]; + const labelMatches = q.length > 0 && item.label.toLowerCase().includes(q); if (!nodeMap.has(item.iri)) { nodeMap.set(item.iri, { @@ -153,12 +159,12 @@ export function mergePathsIntoTree(paths: AncestorPath[]): EntityTreeNode[] { isLoading: false, hasChildren: item.hasChildren, entityType: "class", - isSearchMatch: matchIris.has(item.iri), + isSearchMatch: matchIris.has(item.iri) || labelMatches, }); } else { const existing = nodeMap.get(item.iri)!; existing.hasChildren = existing.hasChildren || item.hasChildren; - if (matchIris.has(item.iri)) { + if (matchIris.has(item.iri) || labelMatches) { existing.isSearchMatch = true; } } From 559796cbc647e95190a645c6dfc43771b79aa155 Mon Sep 17 00:00:00 2001 From: R-Hart80 Date: Sat, 23 May 2026 16:38:00 -0300 Subject: [PATCH 2/2] fix: add searchQuery to useFilteredTree effect dependency array Missing dep caused stale tree highlights when the search query changed but searchResults did not (e.g. same result set, different query string). Co-Authored-By: Claude Sonnet 4.6 --- lib/hooks/useFilteredTree.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/hooks/useFilteredTree.ts b/lib/hooks/useFilteredTree.ts index 49258428..4ea61839 100644 --- a/lib/hooks/useFilteredTree.ts +++ b/lib/hooks/useFilteredTree.ts @@ -109,7 +109,7 @@ export function useFilteredTree({ } } })(); - }, [searchResults, projectId, accessToken, branch]); + }, [searchResults, searchQuery, projectId, accessToken, branch]); return { filteredNodes, isBuilding, firstMatchIri, truncated }; }