From e68239aab3506627cc17c25f8e715c13f1497496 Mon Sep 17 00:00:00 2001 From: damienriehl Date: Tue, 7 Apr 2026 18:08:04 -0500 Subject: [PATCH 01/47] refactor(graph): replace client-side graph builder with server-side BFS Port entity graph from folio-mapper to use server-driven BFS endpoint. Replace buildGraphData.ts and elkLayout.ts with useELKLayout hook and lib/graph/utils.ts. Add EntityGraphModal for full-screen graph view. Update OntologyGraph, OntologyNode, OntologyEdge for server data model. Remove accessToken and labelHints props from layout components. - New: lib/api/graph.ts (API client for graph endpoint) - New: lib/graph/useELKLayout.ts (ELK layout hook from folio-mapper) - New: lib/graph/utils.ts (extractTreeLabelMap utility) - New: components/graph/EntityGraphModal.tsx (full-screen overlay) - Rewritten: useGraphData.ts (single API call + progressive expansion) - Updated: OntologyGraph, OntologyNode, OntologyEdge - Updated: DeveloperEditorLayout, StandardEditorLayout Ref: CatholicOS/ontokit-web#81 Co-Authored-By: Claude Opus 4.6 (1M context) --- __tests__/lib/graph/buildGraphData.test.ts | 292 -------------- __tests__/lib/graph/useELKLayout.test.ts | 143 +++++++ __tests__/lib/graph/utils.test.ts | 99 +++++ __tests__/lib/hooks/useGraphData.test.ts | 312 +++++++------- .../developer/DeveloperEditorLayout.tsx | 4 +- .../editor/standard/StandardEditorLayout.tsx | 33 +- components/graph/EntityGraphModal.tsx | 107 +++++ components/graph/OntologyEdge.tsx | 6 +- components/graph/OntologyGraph.tsx | 203 +++++----- components/graph/OntologyNode.tsx | 2 +- lib/api/graph.ts | 64 +++ lib/graph/buildGraphData.ts | 214 ---------- lib/graph/elkLayout.ts | 44 -- lib/graph/useELKLayout.ts | 112 ++++++ lib/graph/utils.ts | 16 + lib/hooks/useGraphData.ts | 379 ++++-------------- 16 files changed, 916 insertions(+), 1114 deletions(-) delete mode 100644 __tests__/lib/graph/buildGraphData.test.ts create mode 100644 __tests__/lib/graph/useELKLayout.test.ts create mode 100644 __tests__/lib/graph/utils.test.ts create mode 100644 components/graph/EntityGraphModal.tsx create mode 100644 lib/api/graph.ts delete mode 100644 lib/graph/buildGraphData.ts delete mode 100644 lib/graph/elkLayout.ts create mode 100644 lib/graph/useELKLayout.ts create mode 100644 lib/graph/utils.ts diff --git a/__tests__/lib/graph/buildGraphData.test.ts b/__tests__/lib/graph/buildGraphData.test.ts deleted file mode 100644 index 783b2533..00000000 --- a/__tests__/lib/graph/buildGraphData.test.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - getSeeAlsoIris, - extractTreeLabelMap, - buildGraphFromClassDetail, -} from "@/lib/graph/buildGraphData"; -import type { OWLClassDetail } from "@/lib/api/client"; -import type { ClassTreeNode } from "@/lib/ontology/types"; - -/** Helper to build a minimal OWLClassDetail for testing. */ -function makeClassDetail(overrides: Partial = {}): OWLClassDetail { - return { - iri: "urn:test", - labels: [], - comments: [], - deprecated: false, - parent_iris: [], - parent_labels: {}, - equivalent_iris: [], - disjoint_iris: [], - child_count: 0, - instance_count: 0, - is_defined: false, - annotations: [], - ...overrides, - }; -} - -// ─── getSeeAlsoIris ───────────────────────────────────────────── - -describe("getSeeAlsoIris", () => { - it("extracts seeAlso HTTP/HTTPS IRIs from annotations", () => { - const detail = makeClassDetail({ - annotations: [ - { - property_iri: "http://www.w3.org/2000/01/rdf-schema#seeAlso", - property_label: "seeAlso", - values: [ - { value: "https://example.org/related", lang: "" }, - { value: "http://example.org/other", lang: "" }, - ], - }, - ], - }); - - expect(getSeeAlsoIris(detail)).toEqual([ - "https://example.org/related", - "http://example.org/other", - ]); - }); - - it("ignores non-URL values", () => { - const detail = makeClassDetail({ - annotations: [ - { - property_iri: "http://www.w3.org/2000/01/rdf-schema#seeAlso", - property_label: "seeAlso", - values: [ - { value: "just some text", lang: "" }, - { value: "urn:not-http", lang: "" }, - ], - }, - ], - }); - - expect(getSeeAlsoIris(detail)).toEqual([]); - }); - - it("returns empty array when no seeAlso annotations", () => { - const detail = makeClassDetail({ - annotations: [ - { - property_iri: "http://www.w3.org/2000/01/rdf-schema#label", - property_label: "label", - values: [{ value: "Test", lang: "en" }], - }, - ], - }); - - expect(getSeeAlsoIris(detail)).toEqual([]); - }); -}); - -// ─── extractTreeLabelMap ──────────────────────────────────────── - -describe("extractTreeLabelMap", () => { - it("builds IRI→label map from flat list", () => { - const nodes: ClassTreeNode[] = [ - { - iri: "urn:a", - label: "Alpha", - children: [], - isExpanded: false, - isLoading: false, - hasChildren: false, - }, - { - iri: "urn:b", - label: "Beta", - children: [], - isExpanded: false, - isLoading: false, - hasChildren: false, - }, - ]; - - const map = extractTreeLabelMap(nodes); - - expect(map.get("urn:a")).toBe("Alpha"); - expect(map.get("urn:b")).toBe("Beta"); - expect(map.size).toBe(2); - }); - - it("walks nested children recursively", () => { - const nodes: ClassTreeNode[] = [ - { - iri: "urn:parent", - label: "Parent", - children: [ - { - iri: "urn:child", - label: "Child", - children: [ - { - iri: "urn:grandchild", - label: "GrandChild", - children: [], - isExpanded: false, - isLoading: false, - hasChildren: false, - }, - ], - isExpanded: false, - isLoading: false, - hasChildren: true, - }, - ], - isExpanded: false, - isLoading: false, - hasChildren: true, - }, - ]; - - const map = extractTreeLabelMap(nodes); - - expect(map.size).toBe(3); - expect(map.get("urn:grandchild")).toBe("GrandChild"); - }); - - it("skips nodes without labels", () => { - const nodes: ClassTreeNode[] = [ - { - iri: "urn:labelled", - label: "Has Label", - children: [], - isExpanded: false, - isLoading: false, - hasChildren: false, - }, - { - iri: "urn:unlabelled", - label: "", - children: [], - isExpanded: false, - isLoading: false, - hasChildren: false, - }, - ]; - - const map = extractTreeLabelMap(nodes); - - expect(map.has("urn:labelled")).toBe(true); - expect(map.has("urn:unlabelled")).toBe(false); - }); -}); - -// ─── buildGraphFromClassDetail ────────────────────────────────── - -describe("buildGraphFromClassDetail", () => { - it("always includes the focus node in output", () => { - const focusIri = "urn:focus"; - const resolved = new Map([ - [focusIri, makeClassDetail({ iri: focusIri, labels: [{ value: "Focus", lang: "" }] })], - ]); - - const result = buildGraphFromClassDetail(focusIri, resolved); - - const focusNode = result.nodes.find((n) => n.id === focusIri); - expect(focusNode).toBeDefined(); - expect(focusNode!.nodeType).toBe("focus"); - }); - - it("preserves nodes connected by edges", () => { - const focusIri = "urn:focus"; - const parentIri = "urn:parent"; - const resolved = new Map([ - [ - focusIri, - makeClassDetail({ - iri: focusIri, - parent_iris: [parentIri], - labels: [{ value: "Focus", lang: "" }], - }), - ], - ]); - - const result = buildGraphFromClassDetail(focusIri, resolved); - - expect(result.nodes.some((n) => n.id === parentIri)).toBe(true); - expect(result.edges.some((e) => e.source === focusIri && e.target === parentIri)).toBe(true); - }); - - it("prunes nodes orphaned by child-cap filtering", () => { - const focusIri = "urn:focus"; - const parentIri = "urn:parent"; - - // Create a parent with more than 20 children (child-cap = 20) - // The focus + 20 other children = 21 total → last one gets pruned - const childDetails: [string, OWLClassDetail][] = []; - for (let i = 0; i < 21; i++) { - const childIri = `urn:child-${i}`; - childDetails.push([ - childIri, - makeClassDetail({ - iri: childIri, - parent_iris: [parentIri], - labels: [{ value: `Child ${i}`, lang: "" }], - }), - ]); - } - - // Insert focus AFTER the 21 siblings so it's not kept just by insertion order - const resolved = new Map([ - ...childDetails, - [ - focusIri, - makeClassDetail({ - iri: focusIri, - parent_iris: [parentIri], - labels: [{ value: "Focus", lang: "" }], - }), - ], - ]); - - const result = buildGraphFromClassDetail(focusIri, resolved); - - // At most 20 capped siblings + the focus edge (always preserved) - const subClassEdges = result.edges.filter( - (e) => e.target === parentIri && e.edgeType === "subClassOf", - ); - expect(subClassEdges.length).toBeLessThanOrEqual(21); - - // Pruned children should not appear in nodes - const nodeIds = new Set(result.nodes.map((n) => n.id)); - const orphanedChildren = childDetails - .map(([iri]) => iri) - .filter((iri) => !subClassEdges.some((e) => e.source === iri)); - - for (const orphan of orphanedChildren) { - // Orphaned children (not connected by any edge) are pruned unless they're the focus - if (orphan !== focusIri) { - expect(nodeIds.has(orphan)).toBe(false); - } - } - - // The focus node's edge to its parent must survive the cap - expect( - result.edges.some((e) => e.source === focusIri && e.target === parentIri), - ).toBe(true); - }); - - it("excludes owl:Thing from nodes and edges", () => { - const focusIri = "urn:focus"; - const owlThing = "http://www.w3.org/2002/07/owl#Thing"; - - const resolved = new Map([ - [ - focusIri, - makeClassDetail({ - iri: focusIri, - parent_iris: [owlThing], - labels: [{ value: "Focus", lang: "" }], - }), - ], - ]); - - const result = buildGraphFromClassDetail(focusIri, resolved); - - expect(result.nodes.some((n) => n.id === owlThing)).toBe(false); - expect(result.edges.some((e) => e.source === owlThing || e.target === owlThing)).toBe(false); - }); -}); diff --git a/__tests__/lib/graph/useELKLayout.test.ts b/__tests__/lib/graph/useELKLayout.test.ts new file mode 100644 index 00000000..8f764a22 --- /dev/null +++ b/__tests__/lib/graph/useELKLayout.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useELKLayout } from "@/lib/graph/useELKLayout"; +import type { EntityGraphResponse } from "@/lib/api/graph"; + +// Mock elkjs to avoid loading the actual WASM module +vi.mock("elkjs/lib/elk.bundled.js", () => ({ + default: class MockELK { + async layout(graph: { children: Array<{ id: string; width: number; height: number }>; edges: Array<{ id: string }> }) { + return { + children: graph.children.map((child, idx) => ({ + ...child, + x: idx * 200, + y: idx * 100, + })), + edges: graph.edges, + }; + } + }, +})); + +function makeGraphResponse(nodeCount = 3): EntityGraphResponse { + const nodes = Array.from({ length: nodeCount }, (_, i) => ({ + id: `urn:node-${i}`, + label: `Node ${i}`, + iri: `urn:node-${i}`, + definition: null, + is_focus: i === 0, + is_root: i === nodeCount - 1, + depth: i, + node_type: i === 0 ? "focus" : "class", + child_count: nodeCount - i - 1, + })); + + const edges = Array.from({ length: nodeCount - 1 }, (_, i) => ({ + id: `subClassOf:urn:node-${i}:urn:node-${i + 1}`, + source: `urn:node-${i}`, + target: `urn:node-${i + 1}`, + edge_type: "subClassOf" as const, + label: null, + })); + + return { + focus_iri: "urn:node-0", + focus_label: "Node 0", + nodes, + edges, + truncated: false, + total_concept_count: nodeCount, + }; +} + +describe("useELKLayout", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("starts with empty nodes and edges", () => { + const { result } = renderHook(() => useELKLayout()); + + expect(result.current.nodes).toEqual([]); + expect(result.current.edges).toEqual([]); + expect(result.current.isLayouting).toBe(false); + }); + + it("computes layout from graph response", async () => { + const data = makeGraphResponse(3); + const { result } = renderHook(() => useELKLayout()); + + await act(async () => { + await result.current.runLayout(data); + }); + + expect(result.current.nodes).toHaveLength(3); + expect(result.current.edges).toHaveLength(2); + expect(result.current.isLayouting).toBe(false); + }); + + it("assigns correct node type from backend data", async () => { + const data = makeGraphResponse(2); + const { result } = renderHook(() => useELKLayout()); + + await act(async () => { + await result.current.runLayout(data); + }); + + const focusNode = result.current.nodes.find((n) => n.id === "urn:node-0"); + expect(focusNode?.data.nodeType).toBe("focus"); + expect(focusNode?.type).toBe("ontologyNode"); + }); + + it("assigns correct edge type from backend data", async () => { + const data = makeGraphResponse(2); + const { result } = renderHook(() => useELKLayout()); + + await act(async () => { + await result.current.runLayout(data); + }); + + const edge = result.current.edges[0]; + expect(edge.data?.edgeType).toBe("subClassOf"); + expect(edge.type).toBe("ontologyEdge"); + }); + + it("positions nodes using ELK output", async () => { + const data = makeGraphResponse(2); + const { result } = renderHook(() => useELKLayout()); + + await act(async () => { + await result.current.runLayout(data); + }); + + // Mock positions x at idx*200, y at idx*100 + expect(result.current.nodes[0].position).toEqual({ x: 0, y: 0 }); + expect(result.current.nodes[1].position).toEqual({ x: 200, y: 100 }); + }); + + it("calculates dynamic node width from label length", async () => { + const data = makeGraphResponse(1); + data.nodes[0].label = "A very long label for testing width"; + const { result } = renderHook(() => useELKLayout()); + + await act(async () => { + await result.current.runLayout(data); + }); + + // Node should exist and have been laid out + expect(result.current.nodes).toHaveLength(1); + }); + + it("adds arrowhead marker only for subClassOf edges", async () => { + const data = makeGraphResponse(2); + data.edges[0].edge_type = "seeAlso"; + const { result } = renderHook(() => useELKLayout()); + + await act(async () => { + await result.current.runLayout(data); + }); + + const edge = result.current.edges[0]; + expect(edge.markerEnd).toBeUndefined(); + }); +}); diff --git a/__tests__/lib/graph/utils.test.ts b/__tests__/lib/graph/utils.test.ts new file mode 100644 index 00000000..515d2a76 --- /dev/null +++ b/__tests__/lib/graph/utils.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import { extractTreeLabelMap } from "@/lib/graph/utils"; +import type { ClassTreeNode } from "@/lib/ontology/types"; + +describe("extractTreeLabelMap", () => { + it("builds IRI→label map from flat list", () => { + const nodes: ClassTreeNode[] = [ + { + iri: "urn:a", + label: "Alpha", + children: [], + isExpanded: false, + isLoading: false, + hasChildren: false, + }, + { + iri: "urn:b", + label: "Beta", + children: [], + isExpanded: false, + isLoading: false, + hasChildren: false, + }, + ]; + + const map = extractTreeLabelMap(nodes); + + expect(map.get("urn:a")).toBe("Alpha"); + expect(map.get("urn:b")).toBe("Beta"); + expect(map.size).toBe(2); + }); + + it("walks nested children recursively", () => { + const nodes: ClassTreeNode[] = [ + { + iri: "urn:parent", + label: "Parent", + children: [ + { + iri: "urn:child", + label: "Child", + children: [ + { + iri: "urn:grandchild", + label: "GrandChild", + children: [], + isExpanded: false, + isLoading: false, + hasChildren: false, + }, + ], + isExpanded: false, + isLoading: false, + hasChildren: true, + }, + ], + isExpanded: false, + isLoading: false, + hasChildren: true, + }, + ]; + + const map = extractTreeLabelMap(nodes); + + expect(map.size).toBe(3); + expect(map.get("urn:grandchild")).toBe("GrandChild"); + }); + + it("skips nodes without labels", () => { + const nodes: ClassTreeNode[] = [ + { + iri: "urn:labelled", + label: "Has Label", + children: [], + isExpanded: false, + isLoading: false, + hasChildren: false, + }, + { + iri: "urn:unlabelled", + label: "", + children: [], + isExpanded: false, + isLoading: false, + hasChildren: false, + }, + ]; + + const map = extractTreeLabelMap(nodes); + + expect(map.has("urn:labelled")).toBe(true); + expect(map.has("urn:unlabelled")).toBe(false); + }); + + it("returns empty map for empty input", () => { + const map = extractTreeLabelMap([]); + expect(map.size).toBe(0); + }); +}); diff --git a/__tests__/lib/hooks/useGraphData.test.ts b/__tests__/lib/hooks/useGraphData.test.ts index 33410ce5..4a33f740 100644 --- a/__tests__/lib/hooks/useGraphData.test.ts +++ b/__tests__/lib/hooks/useGraphData.test.ts @@ -1,221 +1,233 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { renderHook, waitFor, act } from "@testing-library/react"; import { useGraphData } from "@/lib/hooks/useGraphData"; +import { graphApi, type EntityGraphResponse } from "@/lib/api/graph"; -// Mock API and graph builder -vi.mock("@/lib/api/client", () => ({ - projectOntologyApi: { - getClassDetail: vi.fn(), - getClassAncestors: vi.fn(), - searchEntities: vi.fn(), +vi.mock("@/lib/api/graph", () => ({ + graphApi: { + getEntityGraph: vi.fn(), }, })); -vi.mock("@/lib/graph/buildGraphData", () => ({ - buildGraphFromClassDetail: vi.fn(), - getSeeAlsoIris: vi.fn().mockReturnValue([]), -})); - -vi.mock("@/lib/utils", () => ({ - getLocalName: vi.fn((iri: string) => { - const hash = iri.lastIndexOf("#"); - if (hash >= 0) return iri.slice(hash + 1); - const slash = iri.lastIndexOf("/"); - if (slash >= 0) return iri.slice(slash + 1); - return iri; - }), -})); - -import { projectOntologyApi } from "@/lib/api/client"; -import { buildGraphFromClassDetail } from "@/lib/graph/buildGraphData"; - -const mockedGetClassDetail = projectOntologyApi.getClassDetail as ReturnType; -const mockedGetClassAncestors = projectOntologyApi.getClassAncestors as ReturnType; -const mockedBuildGraph = buildGraphFromClassDetail as ReturnType; +const mockGetEntityGraph = vi.mocked(graphApi.getEntityGraph); -function makeDetail(iri: string, parentIris: string[] = []) { +function makeGraphResponse(overrides: Partial = {}): EntityGraphResponse { return { - iri, - labels: [{ value: iri.split("/").pop() || iri, lang: "en" }], - comments: [], - parent_iris: parentIris, - annotations: [], - deprecated: false, - equivalent_iris: [], - disjoint_iris: [], + focus_iri: "urn:focus", + focus_label: "Focus", + nodes: [ + { + id: "urn:focus", + label: "Focus", + iri: "urn:focus", + definition: null, + is_focus: true, + is_root: false, + depth: 0, + node_type: "focus", + child_count: 2, + }, + { + id: "urn:parent", + label: "Parent", + iri: "urn:parent", + definition: null, + is_focus: false, + is_root: true, + depth: 1, + node_type: "root", + child_count: 5, + }, + ], + edges: [ + { + id: "subClassOf:urn:focus:urn:parent", + source: "urn:focus", + target: "urn:parent", + edge_type: "subClassOf", + label: null, + }, + ], + truncated: false, + total_concept_count: 2, + ...overrides, }; } -beforeEach(() => { - vi.clearAllMocks(); - mockedBuildGraph.mockReturnValue({ - nodes: [{ id: "http://example.org/A", label: "A", nodeType: "focus" }], - edges: [], - }); - mockedGetClassAncestors.mockResolvedValue({ nodes: [] }); -}); - describe("useGraphData", () => { - it("returns null graphData when focusIri is null", () => { - const { result } = renderHook(() => - useGraphData({ - focusIri: null, - projectId: "proj-1", - accessToken: "token", - }), - ); + beforeEach(() => { + vi.clearAllMocks(); + }); - expect(result.current.graphData).toBeNull(); - expect(result.current.isLoading).toBe(false); + afterEach(() => { + vi.restoreAllMocks(); }); - it("returns null graphData when accessToken is missing", () => { + it("returns null graphData when focusIri is null", () => { const { result } = renderHook(() => - useGraphData({ - focusIri: "http://example.org/A", - projectId: "proj-1", - }), + useGraphData({ focusIri: null, projectId: "proj-1" }), ); expect(result.current.graphData).toBeNull(); + expect(result.current.isLoading).toBe(false); + expect(mockGetEntityGraph).not.toHaveBeenCalled(); }); - it("fetches focus node and builds graph", async () => { - const detail = makeDetail("http://example.org/A"); - mockedGetClassDetail.mockResolvedValue(detail); + it("fetches graph data when focusIri is provided", async () => { + const response = makeGraphResponse(); + mockGetEntityGraph.mockResolvedValue(response); const { result } = renderHook(() => - useGraphData({ - focusIri: "http://example.org/A", - projectId: "proj-1", - accessToken: "token", - }), + useGraphData({ focusIri: "urn:focus", projectId: "proj-1", branch: "main" }), ); - await waitFor(() => expect(result.current.isLoading).toBe(false)); + await waitFor(() => { + expect(result.current.graphData).toEqual(response); + }); - expect(mockedGetClassDetail).toHaveBeenCalledWith( - "proj-1", - "http://example.org/A", - "token", - undefined, - ); - expect(mockedBuildGraph).toHaveBeenCalled(); - expect(result.current.graphData).not.toBeNull(); + expect(mockGetEntityGraph).toHaveBeenCalledWith("proj-1", "urn:focus", { + branch: "main", + ancestorsDepth: 5, + descendantsDepth: 0, + }); }); - it("fetches parent nodes at depth 1", async () => { - const detailA = makeDetail("http://example.org/A", ["http://example.org/B"]); - const detailB = makeDetail("http://example.org/B"); - - mockedGetClassDetail - .mockResolvedValueOnce(detailA) // focus node - .mockResolvedValueOnce(detailB); // parent node + it("passes descendantsDepth=2 when showDescendants is toggled", async () => { + const response = makeGraphResponse(); + mockGetEntityGraph.mockResolvedValue(response); const { result } = renderHook(() => - useGraphData({ - focusIri: "http://example.org/A", - projectId: "proj-1", - accessToken: "token", - }), + useGraphData({ focusIri: "urn:focus", projectId: "proj-1" }), ); - await waitFor(() => expect(result.current.isLoading).toBe(false)); + await waitFor(() => { + expect(result.current.graphData).not.toBeNull(); + }); + + act(() => { + result.current.setShowDescendants(true); + }); - expect(mockedGetClassDetail).toHaveBeenCalledTimes(2); + await waitFor(() => { + expect(mockGetEntityGraph).toHaveBeenCalledWith("proj-1", "urn:focus", { + branch: undefined, + ancestorsDepth: 5, + descendantsDepth: 2, + }); + }); }); - it("handles fetch errors for focus node gracefully", async () => { - mockedGetClassDetail.mockRejectedValue(new Error("Not found")); + it("sets graphData to null on API error", async () => { + mockGetEntityGraph.mockRejectedValue(new Error("Network error")); const { result } = renderHook(() => - useGraphData({ - focusIri: "http://example.org/A", - projectId: "proj-1", - accessToken: "token", - }), + useGraphData({ focusIri: "urn:focus", projectId: "proj-1" }), ); - await waitFor(() => expect(result.current.isLoading).toBe(false)); + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); - // Should still build a graph (single-node graph fallback) - expect(mockedBuildGraph).toHaveBeenCalled(); + expect(result.current.graphData).toBeNull(); }); - it("expandNode fetches detail and rebuilds graph", async () => { - const detailA = makeDetail("http://example.org/A"); - const detailB = makeDetail("http://example.org/B"); + it("merges expanded node data into existing graph", async () => { + const initial = makeGraphResponse(); + const expansion = makeGraphResponse({ + focus_iri: "urn:parent", + focus_label: "Parent", + nodes: [ + { + id: "urn:parent", + label: "Parent", + iri: "urn:parent", + definition: null, + is_focus: true, + is_root: true, + depth: 0, + node_type: "focus", + child_count: 5, + }, + { + id: "urn:grandparent", + label: "GrandParent", + iri: "urn:grandparent", + definition: null, + is_focus: false, + is_root: true, + depth: 1, + node_type: "root", + child_count: 0, + }, + ], + edges: [ + { + id: "subClassOf:urn:parent:urn:grandparent", + source: "urn:parent", + target: "urn:grandparent", + edge_type: "subClassOf", + label: null, + }, + ], + total_concept_count: 2, + }); - mockedGetClassDetail - .mockResolvedValueOnce(detailA) // initial load - .mockResolvedValueOnce(detailB); // expand node + mockGetEntityGraph + .mockResolvedValueOnce(initial) + .mockResolvedValueOnce(expansion); const { result } = renderHook(() => - useGraphData({ - focusIri: "http://example.org/A", - projectId: "proj-1", - accessToken: "token", - }), + useGraphData({ focusIri: "urn:focus", projectId: "proj-1" }), ); - await waitFor(() => expect(result.current.isLoading).toBe(false)); - - mockedBuildGraph.mockClear(); + await waitFor(() => { + expect(result.current.graphData).not.toBeNull(); + }); - await act(async () => { - result.current.expandNode("http://example.org/B"); + act(() => { + result.current.expandNode("urn:parent"); }); - await waitFor(() => expect(result.current.isLoading).toBe(false)); + await waitFor(() => { + const nodes = result.current.graphData!.nodes; + expect(nodes.some((n) => n.id === "urn:grandparent")).toBe(true); + }); - expect(mockedBuildGraph).toHaveBeenCalled(); + // Should not duplicate existing nodes + const nodeIds = result.current.graphData!.nodes.map((n) => n.id); + const parentCount = nodeIds.filter((id) => id === "urn:parent").length; + expect(parentCount).toBe(1); }); - it("resetGraph clears the graph data", async () => { - mockedGetClassDetail.mockResolvedValue( - makeDetail("http://example.org/A"), - ); + it("resets graph data on resetGraph", async () => { + const response = makeGraphResponse(); + mockGetEntityGraph.mockResolvedValue(response); const { result } = renderHook(() => - useGraphData({ - focusIri: "http://example.org/A", - projectId: "proj-1", - accessToken: "token", - }), + useGraphData({ focusIri: "urn:focus", projectId: "proj-1" }), ); - await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(result.current.graphData).not.toBeNull(); + await waitFor(() => { + expect(result.current.graphData).not.toBeNull(); + }); act(() => { result.current.resetGraph(); }); expect(result.current.graphData).toBeNull(); - expect(result.current.resolvedCount).toBe(0); }); - it("passes branch to API calls", async () => { - mockedGetClassDetail.mockResolvedValue( - makeDetail("http://example.org/A"), - ); + it("reports resolvedCount from node count", async () => { + const response = makeGraphResponse(); + mockGetEntityGraph.mockResolvedValue(response); const { result } = renderHook(() => - useGraphData({ - focusIri: "http://example.org/A", - projectId: "proj-1", - accessToken: "token", - branch: "dev", - }), + useGraphData({ focusIri: "urn:focus", projectId: "proj-1" }), ); - await waitFor(() => expect(result.current.isLoading).toBe(false)); - - expect(mockedGetClassDetail).toHaveBeenCalledWith( - "proj-1", - "http://example.org/A", - "token", - "dev", - ); + await waitFor(() => { + expect(result.current.resolvedCount).toBe(2); + }); }); }); diff --git a/components/editor/developer/DeveloperEditorLayout.tsx b/components/editor/developer/DeveloperEditorLayout.tsx index ad19e4ae..086f69dd 100644 --- a/components/editor/developer/DeveloperEditorLayout.tsx +++ b/components/editor/developer/DeveloperEditorLayout.tsx @@ -28,7 +28,7 @@ import type { OntologySourceEditorRef } from "@/components/editor/OntologySource import type { IriPosition } from "@/lib/editor/indexWorker"; import { useDraftStore } from "@/lib/stores/draftStore"; import { getLocalName } from "@/lib/utils"; -import { extractTreeLabelMap } from "@/lib/graph/buildGraphData"; +import { extractTreeLabelMap } from "@/lib/graph/utils"; import { useAnnounce } from "@/components/ui/ScreenReaderAnnouncer"; const OntologySourceEditor = dynamic( @@ -378,9 +378,7 @@ export function DeveloperEditorLayout(props: DeveloperEditorLayoutProps) { { handleViewModeChange("tree"); navigateToNode(iri); diff --git a/components/editor/standard/StandardEditorLayout.tsx b/components/editor/standard/StandardEditorLayout.tsx index 9bbb3648..659be75d 100644 --- a/components/editor/standard/StandardEditorLayout.tsx +++ b/components/editor/standard/StandardEditorLayout.tsx @@ -13,7 +13,8 @@ import { ResizablePanelDivider } from "@/components/editor/ResizablePanelDivider import { EntityTreeToolbar } from "@/components/editor/shared/EntityTreeToolbar"; import { useTreeSearch } from "@/lib/hooks/useTreeSearch"; import { useFilteredTree } from "@/lib/hooks/useFilteredTree"; -import { Share2, ArrowLeft } from "lucide-react"; +import { Share2, ArrowLeft, Maximize2 } from "lucide-react"; +import { EntityGraphModal } from "@/components/graph/EntityGraphModal"; import { DraggableTreeWrapper } from "@/components/editor/shared/DraggableTreeWrapper"; import { useTreeDragDrop, type DragMode } from "@/lib/hooks/useTreeDragDrop"; import { useToast } from "@/lib/context/ToastContext"; @@ -35,7 +36,7 @@ import type { TurtleIndividualUpdateData } from "@/lib/ontology/turtleIndividual import type { ClassTreeNode } from "@/lib/ontology/types"; import { useDraftStore } from "@/lib/stores/draftStore"; import { getLocalName } from "@/lib/utils"; -import { extractTreeLabelMap } from "@/lib/graph/buildGraphData"; +import { extractTreeLabelMap } from "@/lib/graph/utils"; import { useAnnounce } from "@/components/ui/ScreenReaderAnnouncer"; export interface StandardEditorLayoutProps { @@ -209,6 +210,7 @@ export function StandardEditorLayout(props: StandardEditorLayoutProps) { // Graph view state const [showGraph, setShowGraph] = useState(false); + const [showGraphModal, setShowGraphModal] = useState(false); // Entity tab state const [activeTab, setActiveTab] = useState("classes"); @@ -373,14 +375,21 @@ export function StandardEditorLayout(props: StandardEditorLayoutProps) { Back to Details +
+
{ setShowGraph(false); navigateToNode(iri); @@ -442,6 +451,22 @@ export function StandardEditorLayout(props: StandardEditorLayoutProps) { /> )}
+ + {/* Full-screen graph modal */} + {showGraphModal && selectedIri && ( + { + setShowGraphModal(false); + setShowGraph(false); + navigateToNode(iri); + }} + onClose={() => setShowGraphModal(false)} + /> + )} ); } diff --git a/components/graph/EntityGraphModal.tsx b/components/graph/EntityGraphModal.tsx new file mode 100644 index 00000000..feec3bc1 --- /dev/null +++ b/components/graph/EntityGraphModal.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { Suspense, lazy, useCallback, useEffect } from "react"; +import { X } from "lucide-react"; + +const OntologyGraph = lazy(() => + import("./OntologyGraph").then((m) => ({ default: m.OntologyGraph })), +); + +interface EntityGraphModalProps { + focusIri: string; + label: string; + projectId: string; + branch?: string; + onNavigateToClass?: (iri: string) => void; + onClose: () => void; +} + +export function EntityGraphModal({ + focusIri, + label, + projectId, + branch, + onNavigateToClass, + onClose, +}: EntityGraphModalProps) { + useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") onClose(); + } + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [onClose]); + + const handleNavigate = useCallback( + (iri: string) => { + onNavigateToClass?.(iri); + onClose(); + }, + [onNavigateToClass, onClose], + ); + + return ( +
+
+ {/* Header */} +
+
+ + + + + + + +

+ + Entity Graph + + | + {label} +

+
+ +
+ + {/* Body */} +
+ +
+ + Loading graph viewer... +
+
+ } + > + + +
+
+ + ); +} diff --git a/components/graph/OntologyEdge.tsx b/components/graph/OntologyEdge.tsx index e8f27896..8d33cb62 100644 --- a/components/graph/OntologyEdge.tsx +++ b/components/graph/OntologyEdge.tsx @@ -40,9 +40,9 @@ const edgeTypeConfig: Record void; - labelHints?: Map; } -const nodeTypes = { ontology: OntologyNode }; -const edgeTypes = { ontology: OntologyEdge }; +const nodeTypes = { ontologyNode: OntologyNode }; +const edgeTypes = { ontologyEdge: OntologyEdge }; function GraphLegend() { const [expanded, setExpanded] = useState(false); @@ -49,20 +48,18 @@ function GraphLegend() { {expanded && (
- {/* Node types */}
Nodes
- +
- {/* Edge types */}
Edges
@@ -70,7 +67,7 @@ function GraphLegend() { - +
)} @@ -133,21 +130,22 @@ function LegendEdgeItem({ export function OntologyGraph({ focusIri, projectId, - accessToken, branch, onNavigateToClass, - labelHints, }: OntologyGraphProps) { - const { graphData, isLoading, expandNode, resetGraph, resolvedCount } = - useGraphData({ - focusIri, - projectId, - accessToken, - branch, - labelHints, - }); + const { + graphData, + isLoading, + showDescendants, + setShowDescendants, + expandNode, + resetGraph, + resolvedCount, + } = useGraphData({ focusIri, projectId, branch }); - const [direction, setDirection] = useState<"TB" | "LR">("TB"); + const [direction, setDirection] = useState("TB"); + const toggleDirection = useCallback(() => setDirection((d) => (d === "TB" ? "LR" : "TB")), []); + const { nodes: layoutNodes, edges: layoutEdges, isLayouting, runLayout } = useELKLayout(); const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const [colorMode, setColorMode] = useState("light"); @@ -167,21 +165,45 @@ export function OntologyGraph({ return () => observer.disconnect(); }, []); - const handleNavigate = useCallback( - (iri: string) => { - onNavigateToClass?.(iri); + // Run ELK layout when data or direction changes + useEffect(() => { + if (!graphData || graphData.nodes.length === 0) return; + runLayout(graphData, direction).catch(() => { + // Layout failed — nodes stay empty + }); + }, [graphData, direction, runLayout]); + + // Sync layout results to React Flow state + useEffect(() => { + if (layoutNodes.length > 0) { + setNodes(layoutNodes); + setEdges(layoutEdges); + } + }, [layoutNodes, layoutEdges, setNodes, setEdges]); + + // Progressive expansion: click to expand or navigate + const expandedNodes = useMemo(() => new Set(focusIri ? [focusIri] : []), [focusIri]); + + const handleNodeClick: NodeMouseHandler = useCallback( + (_event, node) => { + if (expandedNodes.has(node.id)) { + onNavigateToClass?.(node.id); + return; + } + expandedNodes.add(node.id); + expandNode(node.id); }, - [onNavigateToClass], + [expandedNodes, expandNode, onNavigateToClass], ); - const handleExpandNode = useCallback( - (iri: string) => { - expandNode(iri); + const handleNodeDoubleClick: NodeMouseHandler = useCallback( + (_event, node) => { + onNavigateToClass?.(node.id); }, - [expandNode], + [onNavigateToClass], ); - // SVG marker definitions for arrows + // SVG marker definitions const arrowMarker = useMemo( () => ( @@ -203,61 +225,6 @@ export function OntologyGraph({ [], ); - // Apply layout when graph data changes - useEffect(() => { - if (!graphData || graphData.nodes.length === 0) { - setNodes([]); - setEdges([]); - return; - } - - let cancelled = false; - - async function applyLayout() { - const positions = await computeLayout( - graphData!.nodes, - graphData!.edges, - direction, - ); - if (cancelled) return; - - const flowNodes: Node[] = graphData!.nodes.map((n) => ({ - id: n.id, - type: "ontology", - position: positions.get(n.id) || { x: 0, y: 0 }, - data: { - label: n.label, - nodeType: n.nodeType, - deprecated: n.deprecated, - childCount: n.childCount, - isExpanded: n.isExpanded, - onNavigate: handleNavigate, - onExpandNode: handleExpandNode, - } as OntologyNodeData, - })); - - const flowEdges: Edge[] = graphData!.edges.map((e) => ({ - id: e.id, - source: e.source, - target: e.target, - type: "ontology", - data: { edgeType: e.edgeType } as OntologyEdgeData, - })); - - setNodes(flowNodes); - setEdges(flowEdges); - } - - applyLayout(); - return () => { - cancelled = true; - }; - }, [graphData, direction, handleNavigate, handleExpandNode, setNodes, setEdges]); - - const toggleDirection = useCallback(() => { - setDirection((prev) => (prev === "TB" ? "LR" : "TB")); - }, []); - if (!focusIri) { return (
@@ -287,6 +254,18 @@ export function OntologyGraph({ )} {direction === "TB" ? "Top-Down" : "Left-Right"} + + {isLayouting && ( + + + Computing layout... + + )}
- {graphData?.nodes.length ?? 0} nodes, {graphData?.edges.length ?? 0} edges - {resolvedCount > 0 && ` (${resolvedCount} resolved)`} + {resolvedCount} nodes, {graphData?.edges.length ?? 0} edges + {graphData?.truncated && ( + + Truncated ({graphData.total_concept_count} discovered) + + )}
{/* Graph */} @@ -307,10 +296,13 @@ export function OntologyGraph({ {arrowMarker} {isLoading && (
-
+
+ + Loading entity graph... +
)} - {graphData && graphData.nodes.length === 1 && graphData.edges.length === 0 && !isLoading && ( + {graphData && graphData.nodes.length === 0 && !isLoading && (

No relationships found for this class @@ -322,31 +314,34 @@ export function OntologyGraph({ edges={edges} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} + onNodeClick={handleNodeClick} + onNodeDoubleClick={handleNodeDoubleClick} nodeTypes={nodeTypes} edgeTypes={edgeTypes} colorMode={colorMode} fitView - fitViewOptions={{ padding: 0.2 }} + fitViewOptions={{ padding: 0.2, duration: 400 }} minZoom={0.1} maxZoom={2} proOptions={{ hideAttribution: true }} + defaultEdgeOptions={{ type: "ontologyEdge" }} > - - - + { + const nodeType = node.data?.nodeType; + if (nodeType === "focus") return "#3b82f6"; + if (nodeType === "root") return "#ef4444"; + if (nodeType === "property") return "#93c5fd"; + if (nodeType === "individual") return "#f9a8d4"; + if (nodeType === "external") return "#e2e8f0"; + return "#d1d5db"; + }} + maskColor="rgba(0,0,0,0.1)" + className="!bg-slate-100 dark:!bg-slate-800" /> diff --git a/components/graph/OntologyNode.tsx b/components/graph/OntologyNode.tsx index 3973b679..b6de30f8 100644 --- a/components/graph/OntologyNode.tsx +++ b/components/graph/OntologyNode.tsx @@ -27,7 +27,7 @@ const nodeStyles: Record = { class: "border border-slate-300 bg-white dark:bg-slate-800 dark:border-slate-600", root: - "border-2 border-amber-400 bg-amber-50 dark:bg-amber-950/30 dark:border-amber-500/70 font-medium", + "border-[3px] border-red-500 bg-red-50 dark:bg-red-950/30 dark:border-red-500/70 font-semibold", individual: "border border-pink-300 bg-pink-50 dark:bg-pink-950/30 dark:border-pink-500/60", property: diff --git a/lib/api/graph.ts b/lib/api/graph.ts new file mode 100644 index 00000000..19c936bf --- /dev/null +++ b/lib/api/graph.ts @@ -0,0 +1,64 @@ +/** + * Entity Graph API client. + * + * Fetches server-side BFS graph data for visualization. + */ + +import { api } from "./client"; + +export interface GraphNode { + id: string; + label: string; + iri: string; + definition: string | null; + is_focus: boolean; + is_root: boolean; + depth: number; + node_type: string; + child_count: number | null; +} + +export interface GraphEdge { + id: string; + source: string; + target: string; + edge_type: "subClassOf" | "equivalentClass" | "disjointWith" | "seeAlso"; + label: string | null; +} + +export interface EntityGraphResponse { + focus_iri: string; + focus_label: string; + nodes: GraphNode[]; + edges: GraphEdge[]; + truncated: boolean; + total_concept_count: number; +} + +export interface FetchGraphOptions { + branch?: string; + ancestorsDepth?: number; + descendantsDepth?: number; + maxNodes?: number; + includeSeeAlso?: boolean; +} + +export const graphApi = { + getEntityGraph: ( + projectId: string, + classIri: string, + options: FetchGraphOptions = {}, + ) => + api.get( + `/api/v1/projects/${projectId}/ontology/graph/${encodeURIComponent(classIri)}`, + { + params: { + branch: options.branch, + ancestors_depth: options.ancestorsDepth, + descendants_depth: options.descendantsDepth, + max_nodes: options.maxNodes, + include_see_also: options.includeSeeAlso, + }, + }, + ), +}; diff --git a/lib/graph/buildGraphData.ts b/lib/graph/buildGraphData.ts deleted file mode 100644 index f6477b01..00000000 --- a/lib/graph/buildGraphData.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { getLocalName } from "@/lib/utils"; -import type { OWLClassDetail } from "@/lib/api/client"; -import type { ClassTreeNode } from "@/lib/ontology/types"; -import type { GraphData, OntologyGraphNode, OntologyGraphEdge, GraphNodeType } from "./types"; - -const WELL_KNOWN_NAMESPACES = new Set([ - "http://www.w3.org/2002/07/owl#", - "http://www.w3.org/2000/01/rdf-schema#", - "http://www.w3.org/1999/02/22-rdf-syntax-ns#", - "http://www.w3.org/2001/XMLSchema#", - "http://purl.org/dc/elements/1.1/", - "http://purl.org/dc/terms/", - "http://xmlns.com/foaf/0.1/", - "http://www.w3.org/2004/02/skos/core#", -]); - -function isExternalIri(iri: string): boolean { - for (const ns of WELL_KNOWN_NAMESPACES) { - if (iri.startsWith(ns)) return true; - } - return false; -} - -const OWL_THING_IRI = "http://www.w3.org/2002/07/owl#Thing"; -const SEEALSO_IRI = "http://www.w3.org/2000/01/rdf-schema#seeAlso"; -const IS_DEFINED_BY_IRI = "http://www.w3.org/2000/01/rdf-schema#isDefinedBy"; -const MAX_CHILDREN_PER_NODE = 20; - -/** - * Extract IRI-valued seeAlso/isDefinedBy targets from a class detail's annotations. - */ -export function getSeeAlsoIris(detail: OWLClassDetail): string[] { - const iris: string[] = []; - for (const annot of detail.annotations) { - if (annot.property_iri === SEEALSO_IRI || annot.property_iri === IS_DEFINED_BY_IRI) { - for (const val of annot.values) { - const v = typeof val === "string" ? val : val.value; - if (v.startsWith("http://") || v.startsWith("https://")) { - iris.push(v); - } - } - } - } - return iris; -} - -/** - * Recursively walk tree nodes to build an IRI → label map. - */ -export function extractTreeLabelMap(nodes: ClassTreeNode[]): Map { - const map = new Map(); - function walk(list: ClassTreeNode[]) { - for (const node of list) { - if (node.label) map.set(node.iri, node.label); - if (node.children.length > 0) walk(node.children); - } - } - walk(nodes); - return map; -} - -/** - * Build graph data from a set of resolved class details centered on a focus IRI. - */ -export function buildGraphFromClassDetail( - focusIri: string, - resolvedNodes: Map, - labelHints?: Map, - entityTypes?: Map, -): GraphData { - const visited = new Set(); - const nodeMap = new Map(); - const edges: OntologyGraphEdge[] = []; - const edgeSet = new Set(); - - // Collect resolved labels from all parent_labels maps so unresolved nodes - // can display human-readable names instead of raw IRI fragments. - const knownLabels = new Map(); - for (const detail of resolvedNodes.values()) { - if (detail.parent_labels) { - for (const [iri, label] of Object.entries(detail.parent_labels)) { - if (label) knownLabels.set(iri, label); - } - } - } - - function addEdge(source: string, target: string, edgeType: OntologyGraphEdge["edgeType"]) { - if (source === OWL_THING_IRI || target === OWL_THING_IRI) return; - const key = `${edgeType}:${source}:${target}`; - if (edgeSet.has(key)) return; - edgeSet.add(key); - edges.push({ id: key, source, target, edgeType }); - } - - function getNodeType(iri: string): GraphNodeType { - if (iri === focusIri) return "focus"; - if (isExternalIri(iri)) return "external"; - // Check entity type for non-class entities - const et = entityTypes?.get(iri); - if (et === "individual") return "individual"; - if (et === "property") return "property"; - if (!resolvedNodes.has(iri)) return "unexplored"; - const detail = resolvedNodes.get(iri)!; - if (detail.parent_iris.length === 0 || detail.parent_iris.every(p => p === OWL_THING_IRI)) return "root"; - return "class"; - } - - function resolveLabel(iri: string): string { - // 1. Resolved node's own labels - const detail = resolvedNodes.get(iri); - if (detail?.labels[0]?.value) return detail.labels[0].value; - // 2. Label from another node's parent_labels map - const known = knownLabels.get(iri); - if (known) return known; - // 3. Label from tree nodes already in memory - const hint = labelHints?.get(iri); - if (hint) return hint; - // 4. Fallback to local name - return getLocalName(iri); - } - - function ensureNode(iri: string): void { - if (iri === OWL_THING_IRI) return; - if (nodeMap.has(iri)) return; - const detail = resolvedNodes.get(iri); - nodeMap.set(iri, { - id: iri, - label: resolveLabel(iri), - nodeType: getNodeType(iri), - deprecated: detail?.deprecated, - childCount: detail?.child_count, - isExpanded: resolvedNodes.has(iri), - }); - } - - function processNode(iri: string): void { - if (visited.has(iri)) return; - visited.add(iri); - - const detail = resolvedNodes.get(iri); - if (!detail) return; - - ensureNode(iri); - - // subClassOf edges: child → parent (natural OWL direction; ELK "UP" places parents at top) - for (const parentIri of detail.parent_iris) { - ensureNode(parentIri); - addEdge(iri, parentIri, "subClassOf"); - } - - // equivalentClass edges - for (const eqIri of detail.equivalent_iris ?? []) { - ensureNode(eqIri); - addEdge(iri, eqIri, "equivalentClass"); - } - - // disjointWith edges - for (const djIri of detail.disjoint_iris ?? []) { - ensureNode(djIri); - addEdge(iri, djIri, "disjointWith"); - } - - // seeAlso / isDefinedBy from annotations - for (const annot of detail.annotations) { - if (annot.property_iri === SEEALSO_IRI || annot.property_iri === IS_DEFINED_BY_IRI) { - for (const val of annot.values) { - const v = typeof val === "string" ? val : val.value; - if (v.startsWith("http://") || v.startsWith("https://")) { - ensureNode(v); - addEdge(iri, v, "seeAlso"); - } - } - } - } - } - - // Process all resolved nodes - for (const iri of resolvedNodes.keys()) { - processNode(iri); - } - - // Ensure focus node exists even if not in resolvedNodes - ensureNode(focusIri); - - // Cap children: for any parent with more than MAX_CHILDREN_PER_NODE children shown, trim. - // Always preserve edges involving the focus node so it's never pruned. - const childrenCount = new Map(); - const filteredEdges = edges.filter((edge) => { - if (edge.edgeType === "subClassOf") { - // source is child, target is parent — always keep the focus node's edges - if (edge.source === focusIri) return true; - const parent = edge.target; - const count = (childrenCount.get(parent) || 0) + 1; - childrenCount.set(parent, count); - if (count > MAX_CHILDREN_PER_NODE) return false; - } - return true; - }); - - // Prune nodes that are not connected after child-cap filtering - const visibleNodeIds = new Set([focusIri]); - for (const edge of filteredEdges) { - visibleNodeIds.add(edge.source); - visibleNodeIds.add(edge.target); - } - const nodes = Array.from(nodeMap.values()).filter((node) => visibleNodeIds.has(node.id)); - - // If no relationships at all - if (filteredEdges.length === 0 && nodes.length <= 1) { - return { nodes, edges: [] }; - } - - return { nodes, edges: filteredEdges }; -} diff --git a/lib/graph/elkLayout.ts b/lib/graph/elkLayout.ts deleted file mode 100644 index d2af9989..00000000 --- a/lib/graph/elkLayout.ts +++ /dev/null @@ -1,44 +0,0 @@ -import ELK from "elkjs/lib/elk.bundled.js"; -import type { OntologyGraphNode, OntologyGraphEdge } from "./types"; - -const elk = new ELK(); - -const NODE_WIDTH = 180; -const NODE_HEIGHT = 44; - -export async function computeLayout( - nodes: OntologyGraphNode[], - edges: OntologyGraphEdge[], - direction: "TB" | "LR" = "TB", -): Promise> { - const graph = { - id: "root", - layoutOptions: { - "elk.algorithm": "layered", - "elk.direction": direction === "TB" ? "UP" : "LEFT", - "elk.layered.crossingMinimization.strategy": "LAYER_SWEEP", - "elk.spacing.nodeNode": "40", - "elk.layered.spacing.nodeNodeBetweenLayers": "80", - "elk.layered.nodePlacement.strategy": "BRANDES_KOEPF", - }, - children: nodes.map((n) => ({ - id: n.id, - width: NODE_WIDTH, - height: NODE_HEIGHT, - })), - edges: edges.map((e) => ({ - id: e.id, - sources: [e.source], - targets: [e.target], - })), - }; - - const layout = await elk.layout(graph); - const positions = new Map(); - - for (const child of layout.children || []) { - positions.set(child.id, { x: child.x ?? 0, y: child.y ?? 0 }); - } - - return positions; -} diff --git a/lib/graph/useELKLayout.ts b/lib/graph/useELKLayout.ts new file mode 100644 index 00000000..b37bbbc4 --- /dev/null +++ b/lib/graph/useELKLayout.ts @@ -0,0 +1,112 @@ +"use client"; + +import { useCallback, useState } from "react"; +import type { Node, Edge } from "@xyflow/react"; +import type { EntityGraphResponse } from "@/lib/api/graph"; +import type { OntologyNodeData } from "@/components/graph/OntologyNode"; +import type { OntologyEdgeData } from "@/components/graph/OntologyEdge"; +import type { GraphNodeType, GraphEdgeType } from "@/lib/graph/types"; + +export type LayoutDirection = "TB" | "LR"; + +interface LayoutResult { + nodes: Node[]; + edges: Edge[]; + isLayouting: boolean; + runLayout: (data: EntityGraphResponse, direction?: LayoutDirection) => Promise; +} + +const NODE_WIDTH = 180; +const NODE_HEIGHT = 44; + +export function useELKLayout(): LayoutResult { + const [nodes, setNodes] = useState[]>([]); + const [edges, setEdges] = useState[]>([]); + const [isLayouting, setIsLayouting] = useState(false); + + const runLayout = useCallback( + async (data: EntityGraphResponse, direction: LayoutDirection = "TB") => { + setIsLayouting(true); + + try { + const ELK = (await import("elkjs/lib/elk.bundled.js")).default; + const elk = new ELK(); + + const elkNodes = data.nodes.map((n) => ({ + id: n.id, + width: Math.max(NODE_WIDTH, n.label.length * 7.5 + 32), + height: NODE_HEIGHT, + })); + + const elkEdges = data.edges.map((e) => ({ + id: e.id, + sources: [e.source], + targets: [e.target], + // seeAlso edges should not influence layering — mark as non-hierarchical + ...(e.edge_type === "seeAlso" + ? { layoutOptions: { "elk.layered.priority.direction": "0" } } + : {}), + })); + + const elkGraph = await elk.layout({ + id: "root", + layoutOptions: { + "elk.algorithm": "layered", + "elk.direction": direction === "TB" ? "DOWN" : "RIGHT", + "elk.spacing.nodeNode": "40", + "elk.layered.spacing.nodeNodeBetweenLayers": "70", + "elk.layered.crossingMinimization.strategy": "LAYER_SWEEP", + "elk.edgeRouting": "SPLINES", + "elk.layered.nodePlacement.strategy": "NETWORK_SIMPLEX", + "elk.layered.cycleBreaking.strategy": "DEPTH_FIRST", + "elk.separateConnectedComponents": "false", + }, + children: elkNodes, + edges: elkEdges, + }); + + const nodeMap = new Map(data.nodes.map((n) => [n.id, n])); + const layoutedNodes: Node[] = (elkGraph.children ?? []).map( + (elkNode) => { + const backendNode = nodeMap.get(elkNode.id)!; + return { + id: elkNode.id, + type: "ontologyNode", + position: { x: elkNode.x ?? 0, y: elkNode.y ?? 0 }, + data: { + label: backendNode.label, + nodeType: (backendNode.node_type || "class") as GraphNodeType, + childCount: backendNode.child_count ?? undefined, + deprecated: false, + isExpanded: false, + }, + }; + }, + ); + + const edgeMarkers: Partial> = { + subClassOf: { type: "arrowclosed", color: "#94a3b8" }, + }; + + const layoutedEdges: Edge[] = data.edges.map((e) => ({ + id: e.id, + source: e.source, + target: e.target, + type: "ontologyEdge", + markerEnd: edgeMarkers[e.edge_type as GraphEdgeType], + data: { + edgeType: e.edge_type as GraphEdgeType, + }, + })); + + setNodes(layoutedNodes); + setEdges(layoutedEdges); + } finally { + setIsLayouting(false); + } + }, + [], + ); + + return { nodes, edges, isLayouting, runLayout }; +} diff --git a/lib/graph/utils.ts b/lib/graph/utils.ts new file mode 100644 index 00000000..16f1bf34 --- /dev/null +++ b/lib/graph/utils.ts @@ -0,0 +1,16 @@ +import type { ClassTreeNode } from "@/lib/ontology/types"; + +/** + * Recursively walk tree nodes to build an IRI → label map. + */ +export function extractTreeLabelMap(nodes: ClassTreeNode[]): Map { + const map = new Map(); + function walk(list: ClassTreeNode[]) { + for (const node of list) { + if (node.label) map.set(node.iri, node.label); + if (node.children.length > 0) walk(node.children); + } + } + walk(nodes); + return map; +} diff --git a/lib/hooks/useGraphData.ts b/lib/hooks/useGraphData.ts index 7e1674d8..66b61383 100644 --- a/lib/hooks/useGraphData.ts +++ b/lib/hooks/useGraphData.ts @@ -1,25 +1,19 @@ "use client"; -import { useState, useCallback, useEffect, useRef } from "react"; -import { projectOntologyApi, type OWLClassDetail } from "@/lib/api/client"; -import { buildGraphFromClassDetail, getSeeAlsoIris } from "@/lib/graph/buildGraphData"; -import { getLocalName } from "@/lib/utils"; -import type { GraphData } from "@/lib/graph/types"; - -const MAX_RESOLVED_NODES = 100; +import { useState, useCallback, useEffect, useMemo, useRef } from "react"; +import { graphApi, type EntityGraphResponse } from "@/lib/api/graph"; interface UseGraphDataOptions { focusIri: string | null; projectId: string; - accessToken?: string; branch?: string; - initialDepth?: number; - labelHints?: Map; } interface UseGraphDataReturn { - graphData: GraphData | null; + graphData: EntityGraphResponse | null; isLoading: boolean; + showDescendants: boolean; + setShowDescendants: (v: boolean) => void; expandNode: (iri: string) => void; resetGraph: () => void; resolvedCount: number; @@ -28,317 +22,104 @@ interface UseGraphDataReturn { export function useGraphData({ focusIri, projectId, - accessToken, branch, - initialDepth = 2, - labelHints, }: UseGraphDataOptions): UseGraphDataReturn { - const [graphData, setGraphData] = useState(null); + const [graphData, setGraphData] = useState(null); const [isLoading, setIsLoading] = useState(false); - const resolvedNodesRef = useRef>(new Map()); - // IRIs that failed getClassDetail (non-class entities) — don't retry - const failedIrisRef = useRef>(new Set()); - // Labels + entity types discovered for non-class entities via search API - const nonClassLabelsRef = useRef>(new Map()); - const [resolvedCount, setResolvedCount] = useState(0); - - const fetchDetail = useCallback( - async (iri: string): Promise => { - if (resolvedNodesRef.current.has(iri)) { - return resolvedNodesRef.current.get(iri)!; - } - if (failedIrisRef.current.has(iri)) return null; - if (resolvedNodesRef.current.size >= MAX_RESOLVED_NODES) return null; - try { - const detail = await projectOntologyApi.getClassDetail( - projectId, - iri, - accessToken, - branch, - ); - resolvedNodesRef.current.set(iri, detail); - return detail; - } catch { - failedIrisRef.current.add(iri); - return null; - } - }, - [projectId, accessToken, branch], - ); - - const fetchNeighbors = useCallback( - async (iris: string[]): Promise => { - const unresolved = iris.filter( - (iri) => - !resolvedNodesRef.current.has(iri) && - !failedIrisRef.current.has(iri) && - resolvedNodesRef.current.size < MAX_RESOLVED_NODES, - ); - if (unresolved.length === 0) return []; - - const results = await Promise.allSettled( - unresolved.map((iri) => fetchDetail(iri)), - ); - - const newNeighborIris: string[] = []; - for (const result of results) { - if (result.status === "fulfilled" && result.value) { - const detail = result.value; - for (const parentIri of detail.parent_iris) { - if (!resolvedNodesRef.current.has(parentIri)) { - newNeighborIris.push(parentIri); - } - } - for (const eqIri of detail.equivalent_iris ?? []) { - if (!resolvedNodesRef.current.has(eqIri)) { - newNeighborIris.push(eqIri); - } - } - for (const djIri of detail.disjoint_iris ?? []) { - if (!resolvedNodesRef.current.has(djIri)) { - newNeighborIris.push(djIri); - } - } - for (const seeAlsoIri of getSeeAlsoIris(detail)) { - if (!resolvedNodesRef.current.has(seeAlsoIri)) { - newNeighborIris.push(seeAlsoIri); - } - } - } - } - return [...new Set(newNeighborIris)]; - }, - [fetchDetail], - ); - - /** - * Resolve full ancestry for all currently resolved class nodes. - * Calls the ancestors endpoint for each class, adds missing ancestor nodes. - */ - const resolveAncestry = useCallback(async () => { - const classIris = [...resolvedNodesRef.current.keys()]; - const MAX_ANCESTOR_CALLS = 50; - let callCount = 0; - - // Collect all ancestor IRIs we need to resolve - const ancestorIrisToResolve = new Set(); - - await Promise.allSettled( - classIris.map(async (iri) => { - if (callCount >= MAX_ANCESTOR_CALLS) return; - callCount++; - try { - const response = await projectOntologyApi.getClassAncestors( - projectId, - iri, - accessToken, - branch, - ); - for (const node of response.nodes) { - if (!resolvedNodesRef.current.has(node.iri) && !failedIrisRef.current.has(node.iri)) { - ancestorIrisToResolve.add(node.iri); - } - } - } catch { - // Ancestors endpoint failed — skip this node - } - }), - ); - - // Fetch full details for discovered ancestors - if (ancestorIrisToResolve.size > 0) { - await Promise.allSettled( - [...ancestorIrisToResolve].map((iri) => fetchDetail(iri)), - ); - } - }, [projectId, accessToken, branch, fetchDetail]); - - /** Collect all IRIs referenced by resolved nodes that are still unresolved. */ - const getUnresolvedReferenced = useCallback((): string[] => { - const referenced = new Set(); - for (const detail of resolvedNodesRef.current.values()) { - for (const iri of detail.parent_iris) referenced.add(iri); - for (const iri of detail.equivalent_iris ?? []) referenced.add(iri); - for (const iri of detail.disjoint_iris ?? []) referenced.add(iri); - for (const iri of getSeeAlsoIris(detail)) referenced.add(iri); - } - return [...referenced].filter( - (iri) => !resolvedNodesRef.current.has(iri) && !failedIrisRef.current.has(iri), - ); - }, []); - - /** - * Resolve labels for non-class entities (individuals, properties) via search API. - * Searches by each IRI's local name and matches by exact IRI in results. - */ - const resolveNonClassLabels = useCallback(async () => { - const needLabels = [...failedIrisRef.current].filter( - (iri) => !nonClassLabelsRef.current.has(iri), - ); - if (needLabels.length === 0) return; - - await Promise.allSettled( - needLabels.map(async (iri) => { - try { - const localName = getLocalName(iri); - const response = await projectOntologyApi.searchEntities( - projectId, - localName, - accessToken, - branch, - ); - const match = response.results.find((r) => r.iri === iri); - if (match) { - nonClassLabelsRef.current.set(iri, { - label: match.label || getLocalName(iri), - entityType: match.entity_type, - }); - } - } catch { - // Search failed for this IRI — label will fall back to getLocalName - } - }), - ); - }, [projectId, accessToken, branch]); + const [showDescendants, setShowDescendants] = useState(false); + const expandedNodes = useRef(new Set()); - const buildGraph = useCallback( - (focus: string) => { - // Merge all label sources: caller hints + non-class entity labels - let mergedHints = labelHints; - let entityTypes: Map | undefined; - if (nonClassLabelsRef.current.size > 0) { - mergedHints = new Map(labelHints); - entityTypes = new Map(); - for (const [iri, info] of nonClassLabelsRef.current) { - if (!mergedHints.has(iri)) mergedHints.set(iri, info.label); - entityTypes.set(iri, info.entityType); - } - } - const data = buildGraphFromClassDetail(focus, resolvedNodesRef.current, mergedHints, entityTypes); - setGraphData(data); - setResolvedCount(resolvedNodesRef.current.size); - }, - [labelHints], - ); - - // Initial load when focus changes + // Fetch graph from backend BFS endpoint useEffect(() => { - if (!focusIri || !accessToken) { + if (!focusIri) { setGraphData(null); return; } let cancelled = false; - - async function loadGraph() { - setIsLoading(true); - resolvedNodesRef.current = new Map(); - failedIrisRef.current = new Set(); - nonClassLabelsRef.current = new Map(); - - try { - // Depth 0: focus node - const focusDetail = await fetchDetail(focusIri!); - if (cancelled || !focusDetail) { - if (!cancelled) { - // Even without detail, show a single-node graph - buildGraph(focusIri!); - } - return; - } - - // Depth 1: immediate neighbors (including seeAlso/isDefinedBy targets) - const depth1Iris = [ - ...focusDetail.parent_iris, - ...(focusDetail.equivalent_iris ?? []), - ...(focusDetail.disjoint_iris ?? []), - ...getSeeAlsoIris(focusDetail), - ]; - const depth2Candidates = await fetchNeighbors(depth1Iris); - if (cancelled) return; - - // Depth 2: neighbors of neighbors (if initialDepth >= 2) - if (initialDepth >= 2 && depth2Candidates.length > 0) { - await fetchNeighbors(depth2Candidates); - if (cancelled) return; - } - - // Label resolution pass: fetch details for any remaining unresolved - // relationship targets so graph nodes show labels instead of opaque IDs - const unresolved = getUnresolvedReferenced(); - if (unresolved.length > 0) { - await Promise.allSettled(unresolved.map((iri) => fetchDetail(iri))); - if (cancelled) return; - } - - // Ancestry resolution: trace all displayed classes back to root - await resolveAncestry(); - if (cancelled) return; - - // Resolve labels for non-class entities (individuals/properties) - if (failedIrisRef.current.size > 0) { - await resolveNonClassLabels(); - if (cancelled) return; - } - - buildGraph(focusIri!); - } finally { + setIsLoading(true); + expandedNodes.current = new Set([focusIri]); + + graphApi + .getEntityGraph(projectId, focusIri, { + branch, + ancestorsDepth: 5, + descendantsDepth: showDescendants ? 2 : 0, + }) + .then((data) => { + if (!cancelled) setGraphData(data); + }) + .catch(() => { + if (!cancelled) setGraphData(null); + }) + .finally(() => { if (!cancelled) setIsLoading(false); - } - } + }); - loadGraph(); return () => { cancelled = true; }; - }, [focusIri, accessToken, branch, fetchDetail, fetchNeighbors, getUnresolvedReferenced, resolveAncestry, resolveNonClassLabels, buildGraph, initialDepth]); + }, [focusIri, projectId, branch, showDescendants]); + // Progressive expansion: fetch 1-hop neighborhood and merge const expandNode = useCallback( - async (iri: string) => { - if (!focusIri) return; - setIsLoading(true); - - try { - const detail = await fetchDetail(iri); - if (detail) { - const neighborIris = [ - ...detail.parent_iris, - ...(detail.equivalent_iris ?? []), - ...(detail.disjoint_iris ?? []), - ...getSeeAlsoIris(detail), - ]; - await fetchNeighbors(neighborIris); - - // Label resolution pass for newly discovered relationship targets - const unresolved = getUnresolvedReferenced(); - if (unresolved.length > 0) { - await Promise.allSettled(unresolved.map((i) => fetchDetail(i))); - } - - // Ancestry resolution for newly discovered nodes - await resolveAncestry(); + (iri: string) => { + if (!graphData || expandedNodes.current.has(iri)) return; + expandedNodes.current.add(iri); - // Resolve labels for non-class entities - if (failedIrisRef.current.size > 0) { - await resolveNonClassLabels(); - } - } - - buildGraph(focusIri); - } finally { - setIsLoading(false); - } + graphApi + .getEntityGraph(projectId, iri, { + branch, + ancestorsDepth: 1, + descendantsDepth: 1, + maxNodes: 50, + }) + .then((newData) => { + setGraphData((prev) => { + if (!prev) return newData; + + const existingNodeIds = new Set(prev.nodes.map((n) => n.id)); + const existingEdgeIds = new Set(prev.edges.map((e) => e.id)); + + return { + ...prev, + nodes: [ + ...prev.nodes, + ...newData.nodes.filter((n) => !existingNodeIds.has(n.id)), + ], + edges: [ + ...prev.edges, + ...newData.edges.filter((e) => !existingEdgeIds.has(e.id)), + ], + total_concept_count: + prev.total_concept_count + newData.nodes.filter((n) => !existingNodeIds.has(n.id)).length, + }; + }); + }) + .catch(() => { + // Silently fail expansion + }); }, - [focusIri, fetchDetail, fetchNeighbors, getUnresolvedReferenced, resolveAncestry, resolveNonClassLabels, buildGraph], + [graphData, projectId, branch], ); const resetGraph = useCallback(() => { - resolvedNodesRef.current = new Map(); - failedIrisRef.current = new Set(); - nonClassLabelsRef.current = new Map(); + expandedNodes.current = new Set(); setGraphData(null); - setResolvedCount(0); }, []); - return { graphData, isLoading, expandNode, resetGraph, resolvedCount }; + const resolvedCount = useMemo( + () => graphData?.nodes.length ?? 0, + [graphData], + ); + + return { + graphData, + isLoading, + showDescendants, + setShowDescendants, + expandNode, + resetGraph, + resolvedCount, + }; } From a14a07a69b7dea6f6303503f2663d8c31880b955 Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Sat, 11 Apr 2026 01:23:32 +0200 Subject: [PATCH 02/47] fix(graph): address review findings for entity graph PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update CONTEXT.md D-05 to match actual project-scoped endpoint - Add language tags to bare fenced code blocks in PLAN.md - Add dialog semantics and focus management to EntityGraphModal - Preserve truncated flag when merging expansion results - Make resetGraph re-fetch baseline via resetKey state - Defer expandedNodes.add until fetch succeeds (retryable on failure) - Single-click only expands nodes; navigation is double-click only - Add layout race condition guard in useELKLayout - Prefer tree label over raw IRI fragment in graph modal title - Fix OntologyGraph expandedNodes mutation lint warning (useMemo → useRef) Co-Authored-By: Claude Opus 4.6 (1M context) --- __tests__/lib/hooks/useGraphData.test.ts | 54 ++++++++++++++++++- .../editor/standard/StandardEditorLayout.tsx | 2 +- components/graph/EntityGraphModal.tsx | 54 +++++++++++++++++-- components/graph/OntologyGraph.tsx | 21 ++++---- lib/graph/useELKLayout.ts | 7 ++- lib/hooks/useGraphData.ts | 10 ++-- 6 files changed, 128 insertions(+), 20 deletions(-) diff --git a/__tests__/lib/hooks/useGraphData.test.ts b/__tests__/lib/hooks/useGraphData.test.ts index 4a33f740..581d6020 100644 --- a/__tests__/lib/hooks/useGraphData.test.ts +++ b/__tests__/lib/hooks/useGraphData.test.ts @@ -199,7 +199,7 @@ describe("useGraphData", () => { expect(parentCount).toBe(1); }); - it("resets graph data on resetGraph", async () => { + it("resetGraph clears data and re-fetches baseline", async () => { const response = makeGraphResponse(); mockGetEntityGraph.mockResolvedValue(response); @@ -211,11 +211,63 @@ describe("useGraphData", () => { expect(result.current.graphData).not.toBeNull(); }); + const callCountBefore = mockGetEntityGraph.mock.calls.length; + act(() => { result.current.resetGraph(); }); expect(result.current.graphData).toBeNull(); + + // Should re-fetch the baseline graph + await waitFor(() => { + expect(mockGetEntityGraph.mock.calls.length).toBeGreaterThan(callCountBefore); + expect(result.current.graphData).not.toBeNull(); + }); + }); + + it("preserves truncated flag when merging expansion", async () => { + const initial = makeGraphResponse({ truncated: false }); + const expansion = makeGraphResponse({ + focus_iri: "urn:parent", + focus_label: "Parent", + truncated: true, + nodes: [ + { + id: "urn:new-node", + label: "New", + iri: "urn:new-node", + definition: null, + is_focus: false, + is_root: false, + depth: 1, + node_type: "class", + child_count: 0, + }, + ], + edges: [], + total_concept_count: 1, + }); + + mockGetEntityGraph + .mockResolvedValueOnce(initial) + .mockResolvedValueOnce(expansion); + + const { result } = renderHook(() => + useGraphData({ focusIri: "urn:focus", projectId: "proj-1" }), + ); + + await waitFor(() => { + expect(result.current.graphData).not.toBeNull(); + }); + + act(() => { + result.current.expandNode("urn:parent"); + }); + + await waitFor(() => { + expect(result.current.graphData!.truncated).toBe(true); + }); }); it("reports resolvedCount from node count", async () => { diff --git a/components/editor/standard/StandardEditorLayout.tsx b/components/editor/standard/StandardEditorLayout.tsx index 659be75d..9f4a2ee7 100644 --- a/components/editor/standard/StandardEditorLayout.tsx +++ b/components/editor/standard/StandardEditorLayout.tsx @@ -456,7 +456,7 @@ export function StandardEditorLayout(props: StandardEditorLayoutProps) { {showGraphModal && selectedIri && ( { diff --git a/components/graph/EntityGraphModal.tsx b/components/graph/EntityGraphModal.tsx index feec3bc1..f1847042 100644 --- a/components/graph/EntityGraphModal.tsx +++ b/components/graph/EntityGraphModal.tsx @@ -1,6 +1,6 @@ "use client"; -import { Suspense, lazy, useCallback, useEffect } from "react"; +import { Suspense, lazy, useCallback, useEffect, useRef } from "react"; import { X } from "lucide-react"; const OntologyGraph = lazy(() => @@ -24,9 +24,48 @@ export function EntityGraphModal({ onNavigateToClass, onClose, }: EntityGraphModalProps) { + const dialogRef = useRef(null); + const previousFocusRef = useRef(null); + + useEffect(() => { + previousFocusRef.current = document.activeElement; + + // Focus the dialog container + dialogRef.current?.focus(); + + return () => { + // Restore focus on unmount + if (previousFocusRef.current instanceof HTMLElement) { + previousFocusRef.current.focus(); + } + }; + }, []); + useEffect(() => { function handleKeyDown(e: KeyboardEvent) { - if (e.key === "Escape") onClose(); + if (e.key === "Escape") { + onClose(); + return; + } + + // Trap focus within the modal + if (e.key === "Tab" && dialogRef.current) { + const focusable = dialogRef.current.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ); + if (focusable.length === 0) return; + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } } document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); @@ -42,7 +81,14 @@ export function EntityGraphModal({ return (

-
+
{/* Header */}
@@ -59,7 +105,7 @@ export function EntityGraphModal({ -

+

Entity Graph diff --git a/components/graph/OntologyGraph.tsx b/components/graph/OntologyGraph.tsx index 9a87ee3a..e17df993 100644 --- a/components/graph/OntologyGraph.tsx +++ b/components/graph/OntologyGraph.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ReactFlow, MiniMap, @@ -181,19 +181,22 @@ export function OntologyGraph({ } }, [layoutNodes, layoutEdges, setNodes, setEdges]); - // Progressive expansion: click to expand or navigate - const expandedNodes = useMemo(() => new Set(focusIri ? [focusIri] : []), [focusIri]); + // Progressive expansion: track which nodes have been clicked to expand + const expandedNodes = useRef(new Set(focusIri ? [focusIri] : [])); + + // Reset expanded set when focus changes + useEffect(() => { + expandedNodes.current = new Set(focusIri ? [focusIri] : []); + }, [focusIri]); const handleNodeClick: NodeMouseHandler = useCallback( (_event, node) => { - if (expandedNodes.has(node.id)) { - onNavigateToClass?.(node.id); - return; + if (!expandedNodes.current.has(node.id)) { + expandedNodes.current.add(node.id); + expandNode(node.id); } - expandedNodes.add(node.id); - expandNode(node.id); }, - [expandedNodes, expandNode, onNavigateToClass], + [expandNode], ); const handleNodeDoubleClick: NodeMouseHandler = useCallback( diff --git a/lib/graph/useELKLayout.ts b/lib/graph/useELKLayout.ts index b37bbbc4..5d48172e 100644 --- a/lib/graph/useELKLayout.ts +++ b/lib/graph/useELKLayout.ts @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import type { Node, Edge } from "@xyflow/react"; import type { EntityGraphResponse } from "@/lib/api/graph"; import type { OntologyNodeData } from "@/components/graph/OntologyNode"; @@ -23,9 +23,11 @@ export function useELKLayout(): LayoutResult { const [nodes, setNodes] = useState[]>([]); const [edges, setEdges] = useState[]>([]); const [isLayouting, setIsLayouting] = useState(false); + const layoutRunRef = useRef(0); const runLayout = useCallback( async (data: EntityGraphResponse, direction: LayoutDirection = "TB") => { + const localRunId = ++layoutRunRef.current; setIsLayouting(true); try { @@ -99,10 +101,11 @@ export function useELKLayout(): LayoutResult { }, })); + if (layoutRunRef.current !== localRunId) return; setNodes(layoutedNodes); setEdges(layoutedEdges); } finally { - setIsLayouting(false); + if (layoutRunRef.current === localRunId) setIsLayouting(false); } }, [], diff --git a/lib/hooks/useGraphData.ts b/lib/hooks/useGraphData.ts index 66b61383..b6350387 100644 --- a/lib/hooks/useGraphData.ts +++ b/lib/hooks/useGraphData.ts @@ -27,12 +27,14 @@ export function useGraphData({ const [graphData, setGraphData] = useState(null); const [isLoading, setIsLoading] = useState(false); const [showDescendants, setShowDescendants] = useState(false); + const [resetKey, setResetKey] = useState(0); const expandedNodes = useRef(new Set()); // Fetch graph from backend BFS endpoint useEffect(() => { if (!focusIri) { setGraphData(null); + setIsLoading(false); return; } @@ -59,13 +61,12 @@ export function useGraphData({ return () => { cancelled = true; }; - }, [focusIri, projectId, branch, showDescendants]); + }, [focusIri, projectId, branch, showDescendants, resetKey]); // Progressive expansion: fetch 1-hop neighborhood and merge const expandNode = useCallback( (iri: string) => { if (!graphData || expandedNodes.current.has(iri)) return; - expandedNodes.current.add(iri); graphApi .getEntityGraph(projectId, iri, { @@ -75,6 +76,7 @@ export function useGraphData({ maxNodes: 50, }) .then((newData) => { + expandedNodes.current.add(iri); setGraphData((prev) => { if (!prev) return newData; @@ -91,13 +93,14 @@ export function useGraphData({ ...prev.edges, ...newData.edges.filter((e) => !existingEdgeIds.has(e.id)), ], + truncated: prev.truncated || newData.truncated, total_concept_count: prev.total_concept_count + newData.nodes.filter((n) => !existingNodeIds.has(n.id)).length, }; }); }) .catch(() => { - // Silently fail expansion + // Expansion failed — node stays retryable }); }, [graphData, projectId, branch], @@ -106,6 +109,7 @@ export function useGraphData({ const resetGraph = useCallback(() => { expandedNodes.current = new Set(); setGraphData(null); + setResetKey((k) => k + 1); }, []); const resolvedCount = useMemo( From 877f5c81c1b40eeec95415bfa8da9fc2d28b7f8f Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Sat, 11 Apr 2026 01:26:03 +0200 Subject: [PATCH 03/47] fix(graph): update stale PLAN.md references and clear graph on empty data - Replace ontologyId with projectId in PLAN.md API examples - Update success criteria endpoint to project-scoped path - Remove layoutNodes.length guard so React Flow clears when graph is empty Co-Authored-By: Claude Opus 4.6 (1M context) --- components/graph/OntologyGraph.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/components/graph/OntologyGraph.tsx b/components/graph/OntologyGraph.tsx index e17df993..1f48359a 100644 --- a/components/graph/OntologyGraph.tsx +++ b/components/graph/OntologyGraph.tsx @@ -175,10 +175,8 @@ export function OntologyGraph({ // Sync layout results to React Flow state useEffect(() => { - if (layoutNodes.length > 0) { - setNodes(layoutNodes); - setEdges(layoutEdges); - } + setNodes(layoutNodes); + setEdges(layoutEdges); }, [layoutNodes, layoutEdges, setNodes, setEdges]); // Progressive expansion: track which nodes have been clicked to expand From 13d00b41bfd659dcfd97f0f466d03a471327dd46 Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Sat, 11 Apr 2026 01:29:05 +0200 Subject: [PATCH 04/47] fix(tests): update graph tests for server-side BFS refactor - Delete elkLayout.test.ts (tests deleted elkLayout.ts module) - Update OntologyEdge test: seeAlso dash pattern "6 3" not "2 4" - Rewrite OntologyGraph tests for new API: - Mock useELKLayout instead of deleted elkLayout - Use EntityGraphResponse shape for mock data - Remove accessToken from props (handled centrally) - Add showDescendants/setShowDescendants to mock return - Update legend labels (Root ancestor, rdfs:seeAlso) - Remove stale "resolved count" and "Fit view" assertions - Add truncation badge test Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/graph/OntologyEdge.test.tsx | 2 +- .../components/graph/OntologyGraph.test.tsx | 74 +++++++----- __tests__/lib/graph/elkLayout.test.ts | 114 ------------------ 3 files changed, 44 insertions(+), 146 deletions(-) delete mode 100644 __tests__/lib/graph/elkLayout.test.ts diff --git a/__tests__/components/graph/OntologyEdge.test.tsx b/__tests__/components/graph/OntologyEdge.test.tsx index 7df9ed3b..1d76c278 100644 --- a/__tests__/components/graph/OntologyEdge.test.tsx +++ b/__tests__/components/graph/OntologyEdge.test.tsx @@ -86,7 +86,7 @@ describe("OntologyEdge", () => { ); const edge = screen.getByTestId("base-edge-edge-1"); - expect(edge.style.strokeDasharray).toBe("2 4"); + expect(edge.style.strokeDasharray).toBe("6 3"); }); it("does not show label by default (not hovered)", () => { diff --git a/__tests__/components/graph/OntologyGraph.test.tsx b/__tests__/components/graph/OntologyGraph.test.tsx index 6b965240..393ab225 100644 --- a/__tests__/components/graph/OntologyGraph.test.tsx +++ b/__tests__/components/graph/OntologyGraph.test.tsx @@ -1,9 +1,11 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; +import type { EntityGraphResponse } from "@/lib/api/graph"; // ── Mocks ────────────────────────────────────────────────────────── const mockUseGraphData = vi.fn(); +const mockRunLayout = vi.fn().mockResolvedValue(undefined); vi.mock("@xyflow/react", () => ({ ReactFlow: ({ children, nodes }: { children?: React.ReactNode; nodes?: unknown[] }) => ( @@ -25,8 +27,13 @@ vi.mock("@/lib/hooks/useGraphData", () => ({ useGraphData: (...args: unknown[]) => mockUseGraphData(...args), })); -vi.mock("@/lib/graph/elkLayout", () => ({ - computeLayout: vi.fn().mockResolvedValue(new Map()), +vi.mock("@/lib/graph/useELKLayout", () => ({ + useELKLayout: () => ({ + nodes: [], + edges: [], + isLayouting: false, + runLayout: mockRunLayout, + }), })); vi.mock("@/components/graph/OntologyNode", () => ({ @@ -46,19 +53,25 @@ import { OntologyGraph } from "@/components/graph/OntologyGraph"; // ── Fixtures ─────────────────────────────────────────────────────── -const mockGraphData = { +const mockGraphData: EntityGraphResponse = { + focus_iri: "iri:Class1", + focus_label: "Class1", nodes: [ - { id: "iri:Class1", label: "Class1", nodeType: "focus" as const, deprecated: false, childCount: 2, isExpanded: true }, - { id: "iri:Class2", label: "Class2", nodeType: "class" as const, deprecated: false, childCount: 0, isExpanded: false }, + { id: "iri:Class1", label: "Class1", iri: "iri:Class1", definition: null, is_focus: true, is_root: false, depth: 0, node_type: "focus", child_count: 2 }, + { id: "iri:Class2", label: "Class2", iri: "iri:Class2", definition: null, is_focus: false, is_root: false, depth: 1, node_type: "class", child_count: 0 }, ], edges: [ - { id: "e1", source: "iri:Class1", target: "iri:Class2", edgeType: "subClassOf" as const }, + { id: "e1", source: "iri:Class1", target: "iri:Class2", edge_type: "subClassOf", label: null }, ], + truncated: false, + total_concept_count: 2, }; const defaultReturn = { graphData: mockGraphData, isLoading: false, + showDescendants: false, + setShowDescendants: vi.fn(), expandNode: vi.fn(), resetGraph: vi.fn(), resolvedCount: 2, @@ -67,7 +80,6 @@ const defaultReturn = { const defaultProps = { focusIri: "iri:Class1", projectId: "proj-1", - accessToken: "tok", branch: "main", onNavigateToClass: vi.fn(), }; @@ -124,11 +136,12 @@ describe("OntologyGraph", () => { // --- No relationships state --- - it("shows no-relationships message when single node and no edges", () => { + it("shows no-relationships message when graphData has no nodes and no edges", () => { mockUseGraphData.mockReturnValue({ ...defaultReturn, graphData: { - nodes: [{ id: "iri:Class1", label: "Class1", nodeType: "focus", deprecated: false, childCount: 0, isExpanded: false }], + ...mockGraphData, + nodes: [], edges: [], }, }); @@ -145,17 +158,6 @@ describe("OntologyGraph", () => { expect(screen.getByText(/2 nodes, 1 edges/)).toBeDefined(); }); - it("shows resolved count when > 0", () => { - render(); - expect(screen.getByText(/2 resolved/)).toBeDefined(); - }); - - it("does not show resolved count when 0", () => { - mockUseGraphData.mockReturnValue({ ...defaultReturn, resolvedCount: 0 }); - render(); - expect(screen.queryByText(/resolved/)).toBeNull(); - }); - // --- Layout direction toggle --- it("defaults to Top-Down layout", () => { @@ -188,17 +190,10 @@ describe("OntologyGraph", () => { expect(defaultReturn.resetGraph).toHaveBeenCalledTimes(1); }); - // --- Fit view button --- - - it("renders Fit view button inside controls", () => { - render(); - expect(screen.getByLabelText("Fit view")).toBeDefined(); - }); - // --- Null graphData --- it("renders without crashing when graphData is null", () => { - mockUseGraphData.mockReturnValue({ ...defaultReturn, graphData: null }); + mockUseGraphData.mockReturnValue({ ...defaultReturn, graphData: null, resolvedCount: 0 }); render(); expect(screen.getByTestId("react-flow")).toBeDefined(); expect(screen.getByText(/0 nodes, 0 edges/)).toBeDefined(); @@ -212,11 +207,28 @@ describe("OntologyGraph", () => { expect.objectContaining({ focusIri: "iri:Class1", projectId: "proj-1", - accessToken: "tok", branch: "main", }) ); }); + + // --- Show Descendants button --- + + it("shows Show Descendants button", () => { + render(); + expect(screen.getByLabelText("Show descendants")).toBeDefined(); + }); + + // --- Truncation badge --- + + it("shows truncation badge when graphData is truncated", () => { + mockUseGraphData.mockReturnValue({ + ...defaultReturn, + graphData: { ...mockGraphData, truncated: true, total_concept_count: 200 }, + }); + render(); + expect(screen.getByText(/Truncated/)).toBeDefined(); + }); }); describe("GraphLegend", () => { @@ -248,7 +260,7 @@ describe("GraphLegend", () => { fireEvent.click(screen.getByLabelText("Expand legend")); expect(screen.getByText("Focus")).toBeDefined(); expect(screen.getByText("Class")).toBeDefined(); - expect(screen.getByText("Root")).toBeDefined(); + expect(screen.getByText("Root ancestor")).toBeDefined(); expect(screen.getByText("Individual")).toBeDefined(); expect(screen.getByText("Property")).toBeDefined(); expect(screen.getByText("External")).toBeDefined(); @@ -261,7 +273,7 @@ describe("GraphLegend", () => { expect(screen.getByText("subClassOf")).toBeDefined(); expect(screen.getByText("equivalentTo")).toBeDefined(); expect(screen.getByText("disjointWith")).toBeDefined(); - expect(screen.getByText("seeAlso")).toBeDefined(); + expect(screen.getByText("rdfs:seeAlso")).toBeDefined(); }); it("collapses legend when clicked again", () => { diff --git a/__tests__/lib/graph/elkLayout.test.ts b/__tests__/lib/graph/elkLayout.test.ts deleted file mode 100644 index b74cb7d5..00000000 --- a/__tests__/lib/graph/elkLayout.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; - -// Define the mock fn at module scope so it's available when the factory runs -const mockLayout = vi.fn(); - -vi.mock("elkjs/lib/elk.bundled.js", () => ({ - default: class MockELK { - layout(...args: unknown[]) { - return mockLayout(...args); - } - }, -})); - -import { computeLayout } from "@/lib/graph/elkLayout"; -import type { - OntologyGraphNode, - OntologyGraphEdge, -} from "@/lib/graph/types"; - -describe("computeLayout", () => { - beforeEach(() => { - mockLayout.mockReset(); - }); - - it("returns positions for each node", async () => { - const nodes: OntologyGraphNode[] = [ - { id: "A", label: "A", nodeType: "class" }, - { id: "B", label: "B", nodeType: "class" }, - ]; - const edges: OntologyGraphEdge[] = [ - { id: "e1", source: "A", target: "B", edgeType: "subClassOf" }, - ]; - - mockLayout.mockResolvedValueOnce({ - children: [ - { id: "A", x: 10, y: 20 }, - { id: "B", x: 30, y: 40 }, - ], - }); - - const positions = await computeLayout(nodes, edges); - expect(positions.get("A")).toEqual({ x: 10, y: 20 }); - expect(positions.get("B")).toEqual({ x: 30, y: 40 }); - }); - - it("defaults to 0 when coordinates are undefined", async () => { - const nodes: OntologyGraphNode[] = [ - { id: "A", label: "A", nodeType: "class" }, - ]; - - mockLayout.mockResolvedValueOnce({ - children: [{ id: "A", x: undefined, y: undefined }], - }); - - const positions = await computeLayout(nodes, []); - expect(positions.get("A")).toEqual({ x: 0, y: 0 }); - }); - - it("returns empty map when no children", async () => { - mockLayout.mockResolvedValueOnce({ children: undefined }); - - const positions = await computeLayout([], []); - expect(positions.size).toBe(0); - }); - - it("passes correct direction for TB", async () => { - mockLayout.mockResolvedValueOnce({ children: [] }); - - await computeLayout([], [], "TB"); - - const graph = mockLayout.mock.calls[0][0]; - expect(graph.layoutOptions["elk.direction"]).toBe("UP"); - }); - - it("passes correct direction for LR", async () => { - mockLayout.mockResolvedValueOnce({ children: [] }); - - await computeLayout([], [], "LR"); - - const graph = mockLayout.mock.calls[0][0]; - expect(graph.layoutOptions["elk.direction"]).toBe("LEFT"); - }); - - it("propagates ELK layout errors", async () => { - mockLayout.mockRejectedValueOnce(new Error("ELK layout failed")); - - await expect(computeLayout([], [])).rejects.toThrow("ELK layout failed"); - }); - - it("maps node and edge properties to ELK format", async () => { - const nodes: OntologyGraphNode[] = [ - { id: "N1", label: "Node 1", nodeType: "focus" }, - ]; - const edges: OntologyGraphEdge[] = [ - { id: "E1", source: "N1", target: "N1", edgeType: "subClassOf" }, - ]; - - mockLayout.mockResolvedValueOnce({ children: [{ id: "N1", x: 0, y: 0 }] }); - - await computeLayout(nodes, edges); - - const graph = mockLayout.mock.calls[0][0]; - expect(graph.children[0]).toMatchObject({ - id: "N1", - width: 180, - height: 44, - }); - expect(graph.edges[0]).toMatchObject({ - id: "E1", - sources: ["N1"], - targets: ["N1"], - }); - }); -}); From 6d4b282b7a337168baa2be79f2920e025a5abf80 Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Sat, 11 Apr 2026 01:37:27 +0200 Subject: [PATCH 05/47] test(graph): add missing tests for EntityGraphModal, graph API, and ELK layout - Add EntityGraphModal tests: dialog semantics, aria attributes, focus management, focus trapping, Escape key, close button, loading fallback - Add graph API client tests: URL encoding, parameter passing, error propagation, optional params handling - Add useELKLayout tests: LR direction, race condition guard, node/edge type assignment, isLayouting state Co-Authored-By: Claude Opus 4.6 (1M context) --- .../graph/EntityGraphModal.test.tsx | 158 ++++++++++++++++++ __tests__/lib/api/graph.test.ts | 112 +++++++++++++ __tests__/lib/graph/useELKLayout.test.ts | 67 ++++++++ 3 files changed, 337 insertions(+) create mode 100644 __tests__/components/graph/EntityGraphModal.test.tsx create mode 100644 __tests__/lib/api/graph.test.ts diff --git a/__tests__/components/graph/EntityGraphModal.test.tsx b/__tests__/components/graph/EntityGraphModal.test.tsx new file mode 100644 index 00000000..dc760cb1 --- /dev/null +++ b/__tests__/components/graph/EntityGraphModal.test.tsx @@ -0,0 +1,158 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; + +// ── Mocks ────────────────────────────────────────────────────────── + +vi.mock("./OntologyGraph", () => ({ + OntologyGraph: ({ focusIri, projectId }: { focusIri: string; projectId: string }) => ( +
+ ), +})); + +import { EntityGraphModal } from "@/components/graph/EntityGraphModal"; + +// ── Fixtures ─────────────────────────────────────────────────────── + +const defaultProps = { + focusIri: "urn:test:Class1", + label: "Class1", + projectId: "proj-1", + branch: "main", + onNavigateToClass: vi.fn(), + onClose: vi.fn(), +}; + +// ── Tests ────────────────────────────────────────────────────────── + +describe("EntityGraphModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // --- Dialog semantics --- + + it("renders with role=dialog and aria-modal", () => { + render(); + const dialog = screen.getByRole("dialog"); + expect(dialog).toBeDefined(); + expect(dialog.getAttribute("aria-modal")).toBe("true"); + }); + + it("has aria-labelledby pointing to the title", () => { + render(); + const dialog = screen.getByRole("dialog"); + const labelId = dialog.getAttribute("aria-labelledby"); + expect(labelId).toBe("entity-graph-title"); + const title = document.getElementById(labelId!); + expect(title).not.toBeNull(); + }); + + // --- Title rendering --- + + it("renders the label in the title", () => { + render(); + expect(screen.getByText("Class1")).toBeDefined(); + expect(screen.getByText("Entity Graph")).toBeDefined(); + }); + + // --- Close button --- + + it("renders a close button with aria-label", () => { + render(); + const closeBtn = screen.getByLabelText("Close graph modal"); + expect(closeBtn).toBeDefined(); + }); + + it("calls onClose when close button is clicked", () => { + render(); + fireEvent.click(screen.getByLabelText("Close graph modal")); + expect(defaultProps.onClose).toHaveBeenCalledTimes(1); + }); + + // --- Escape key --- + + it("calls onClose when Escape is pressed", () => { + render(); + fireEvent.keyDown(document, { key: "Escape" }); + expect(defaultProps.onClose).toHaveBeenCalledTimes(1); + }); + + it("does not call onClose for non-Escape keys", () => { + render(); + fireEvent.keyDown(document, { key: "Enter" }); + expect(defaultProps.onClose).not.toHaveBeenCalled(); + }); + + // --- Focus management --- + + it("focuses the dialog on mount", () => { + render(); + const dialog = screen.getByRole("dialog"); + expect(document.activeElement).toBe(dialog); + }); + + it("restores focus on unmount", () => { + const button = document.createElement("button"); + button.textContent = "Trigger"; + document.body.appendChild(button); + button.focus(); + expect(document.activeElement).toBe(button); + + const { unmount } = render(); + // Focus moved to dialog + expect(document.activeElement).not.toBe(button); + + unmount(); + expect(document.activeElement).toBe(button); + + document.body.removeChild(button); + }); + + // --- Focus trapping --- + + it("traps focus: shift-tab from first focusable wraps to last", () => { + render(); + const dialog = screen.getByRole("dialog"); + const focusables = dialog.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ); + + if (focusables.length > 1) { + focusables[0].focus(); + fireEvent.keyDown(document, { key: "Tab", shiftKey: true }); + expect(document.activeElement).toBe(focusables[focusables.length - 1]); + } + }); + + it("traps focus: tab from last focusable wraps to first", () => { + render(); + const dialog = screen.getByRole("dialog"); + const focusables = dialog.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ); + + if (focusables.length > 1) { + focusables[focusables.length - 1].focus(); + fireEvent.keyDown(document, { key: "Tab", shiftKey: false }); + expect(document.activeElement).toBe(focusables[0]); + } + }); + + // --- Esc keyboard hint --- + + it("renders Esc keyboard hint", () => { + render(); + expect(screen.getByText("Esc")).toBeDefined(); + }); + + // --- Loading fallback --- + + it("renders the Suspense loading fallback", () => { + render(); + expect(screen.getByText("Loading graph viewer...")).toBeDefined(); + }); +}); diff --git a/__tests__/lib/api/graph.test.ts b/__tests__/lib/api/graph.test.ts new file mode 100644 index 00000000..85ae7e05 --- /dev/null +++ b/__tests__/lib/api/graph.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { graphApi } from "@/lib/api/graph"; + +// Mock the api client +vi.mock("@/lib/api/client", () => ({ + api: { + get: vi.fn(), + }, +})); + +import { api } from "@/lib/api/client"; +const mockGet = vi.mocked(api.get); + +describe("graphApi", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("getEntityGraph", () => { + it("calls api.get with correct project-scoped URL", async () => { + mockGet.mockResolvedValue({ nodes: [], edges: [] }); + + await graphApi.getEntityGraph("proj-1", "urn:test:Class1"); + + expect(mockGet).toHaveBeenCalledWith( + `/api/v1/projects/proj-1/ontology/graph/${encodeURIComponent("urn:test:Class1")}`, + expect.any(Object), + ); + }); + + it("encodes class IRI in the URL", async () => { + mockGet.mockResolvedValue({ nodes: [], edges: [] }); + + const iri = "http://example.org/ontology#MyClass"; + await graphApi.getEntityGraph("proj-1", iri); + + const url = mockGet.mock.calls[0][0]; + expect(url).toContain(encodeURIComponent(iri)); + }); + + it("passes branch as query param", async () => { + mockGet.mockResolvedValue({ nodes: [], edges: [] }); + + await graphApi.getEntityGraph("proj-1", "urn:c", { branch: "dev" }); + + const config = mockGet.mock.calls[0][1]; + expect(config.params.branch).toBe("dev"); + }); + + it("passes depth parameters as query params", async () => { + mockGet.mockResolvedValue({ nodes: [], edges: [] }); + + await graphApi.getEntityGraph("proj-1", "urn:c", { + ancestorsDepth: 3, + descendantsDepth: 1, + }); + + const config = mockGet.mock.calls[0][1]; + expect(config.params.ancestors_depth).toBe(3); + expect(config.params.descendants_depth).toBe(1); + }); + + it("passes maxNodes and includeSeeAlso params", async () => { + mockGet.mockResolvedValue({ nodes: [], edges: [] }); + + await graphApi.getEntityGraph("proj-1", "urn:c", { + maxNodes: 100, + includeSeeAlso: false, + }); + + const config = mockGet.mock.calls[0][1]; + expect(config.params.max_nodes).toBe(100); + expect(config.params.include_see_also).toBe(false); + }); + + it("leaves optional params undefined when not provided", async () => { + mockGet.mockResolvedValue({ nodes: [], edges: [] }); + + await graphApi.getEntityGraph("proj-1", "urn:c"); + + const config = mockGet.mock.calls[0][1]; + expect(config.params.branch).toBeUndefined(); + expect(config.params.ancestors_depth).toBeUndefined(); + expect(config.params.descendants_depth).toBeUndefined(); + expect(config.params.max_nodes).toBeUndefined(); + expect(config.params.include_see_also).toBeUndefined(); + }); + + it("returns the API response", async () => { + const mockResponse = { + focus_iri: "urn:c", + focus_label: "C", + nodes: [{ id: "urn:c", label: "C" }], + edges: [], + truncated: false, + total_concept_count: 1, + }; + mockGet.mockResolvedValue(mockResponse); + + const result = await graphApi.getEntityGraph("proj-1", "urn:c"); + expect(result).toEqual(mockResponse); + }); + + it("propagates API errors", async () => { + mockGet.mockRejectedValue(new Error("Network error")); + + await expect( + graphApi.getEntityGraph("proj-1", "urn:c"), + ).rejects.toThrow("Network error"); + }); + }); +}); diff --git a/__tests__/lib/graph/useELKLayout.test.ts b/__tests__/lib/graph/useELKLayout.test.ts index 8f764a22..ebdb6a6d 100644 --- a/__tests__/lib/graph/useELKLayout.test.ts +++ b/__tests__/lib/graph/useELKLayout.test.ts @@ -140,4 +140,71 @@ describe("useELKLayout", () => { const edge = result.current.edges[0]; expect(edge.markerEnd).toBeUndefined(); }); + + it("accepts LR direction parameter", async () => { + const data = makeGraphResponse(2); + const { result } = renderHook(() => useELKLayout()); + + await act(async () => { + await result.current.runLayout(data, "LR"); + }); + + expect(result.current.nodes).toHaveLength(2); + expect(result.current.edges).toHaveLength(1); + }); + + it("only applies results from the latest layout run", async () => { + const data1 = makeGraphResponse(2); + const data2 = makeGraphResponse(3); + const { result } = renderHook(() => useELKLayout()); + + // Start two layouts concurrently — only the second should apply + await act(async () => { + const run1 = result.current.runLayout(data1); + const run2 = result.current.runLayout(data2); + await Promise.all([run1, run2]); + }); + + // The second layout (3 nodes) should win + expect(result.current.nodes).toHaveLength(3); + }); + + it("sets isLayouting during layout computation", async () => { + const data = makeGraphResponse(2); + const { result } = renderHook(() => useELKLayout()); + + expect(result.current.isLayouting).toBe(false); + + await act(async () => { + await result.current.runLayout(data); + }); + + expect(result.current.isLayouting).toBe(false); + }); + + it("assigns ontologyEdge type to all edges", async () => { + const data = makeGraphResponse(3); + const { result } = renderHook(() => useELKLayout()); + + await act(async () => { + await result.current.runLayout(data); + }); + + for (const edge of result.current.edges) { + expect(edge.type).toBe("ontologyEdge"); + } + }); + + it("assigns ontologyNode type to all nodes", async () => { + const data = makeGraphResponse(3); + const { result } = renderHook(() => useELKLayout()); + + await act(async () => { + await result.current.runLayout(data); + }); + + for (const node of result.current.nodes) { + expect(node.type).toBe("ontologyNode"); + } + }); }); From 458f752946de0f7cc221335cab49919d335edee8 Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Sat, 11 Apr 2026 03:44:07 +0200 Subject: [PATCH 06/47] fix: add type assertion for mock call args in graph API tests Cast mockGet.mock.calls[0][1] to typed config object to satisfy strict TypeScript checks on possibly-undefined array access. Co-Authored-By: Claude Opus 4.6 (1M context) --- __tests__/lib/api/graph.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/__tests__/lib/api/graph.test.ts b/__tests__/lib/api/graph.test.ts index 85ae7e05..a418b431 100644 --- a/__tests__/lib/api/graph.test.ts +++ b/__tests__/lib/api/graph.test.ts @@ -43,7 +43,7 @@ describe("graphApi", () => { await graphApi.getEntityGraph("proj-1", "urn:c", { branch: "dev" }); - const config = mockGet.mock.calls[0][1]; + const config = mockGet.mock.calls[0]?.[1] as { params: Record }; expect(config.params.branch).toBe("dev"); }); @@ -55,7 +55,7 @@ describe("graphApi", () => { descendantsDepth: 1, }); - const config = mockGet.mock.calls[0][1]; + const config = mockGet.mock.calls[0]?.[1] as { params: Record }; expect(config.params.ancestors_depth).toBe(3); expect(config.params.descendants_depth).toBe(1); }); @@ -68,7 +68,7 @@ describe("graphApi", () => { includeSeeAlso: false, }); - const config = mockGet.mock.calls[0][1]; + const config = mockGet.mock.calls[0]?.[1] as { params: Record }; expect(config.params.max_nodes).toBe(100); expect(config.params.include_see_also).toBe(false); }); @@ -78,7 +78,7 @@ describe("graphApi", () => { await graphApi.getEntityGraph("proj-1", "urn:c"); - const config = mockGet.mock.calls[0][1]; + const config = mockGet.mock.calls[0]?.[1] as { params: Record }; expect(config.params.branch).toBeUndefined(); expect(config.params.ancestors_depth).toBeUndefined(); expect(config.params.descendants_depth).toBeUndefined(); From b37e4ef04e243a51260213a22e83a05e4766ec81 Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Sat, 11 Apr 2026 03:57:29 +0200 Subject: [PATCH 07/47] test: improve coverage for EntityGraphModal and useGraphData MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix OntologyGraph mock path to match lazy import resolution - Add focusable button to mock for focus trapping test coverage - Add test for handleNavigate callback (onNavigateToClass + onClose) - Add test for Tab without trapping (middle element) - Add tests for expandNode skip (already expanded, null graphData) - EntityGraphModal: 60%→97% statements, 28%→89% branches Co-Authored-By: Claude Opus 4.6 (1M context) --- .../graph/EntityGraphModal.test.tsx | 64 ++++++++++++++----- __tests__/lib/hooks/useGraphData.test.ts | 54 ++++++++++++++++ 2 files changed, 101 insertions(+), 17 deletions(-) diff --git a/__tests__/components/graph/EntityGraphModal.test.tsx b/__tests__/components/graph/EntityGraphModal.test.tsx index dc760cb1..e649c243 100644 --- a/__tests__/components/graph/EntityGraphModal.test.tsx +++ b/__tests__/components/graph/EntityGraphModal.test.tsx @@ -3,10 +3,16 @@ import { render, screen, fireEvent } from "@testing-library/react"; // ── Mocks ────────────────────────────────────────────────────────── -vi.mock("./OntologyGraph", () => ({ - OntologyGraph: ({ focusIri, projectId }: { focusIri: string; projectId: string }) => ( -
- ), +let capturedOnNavigateToClass: ((iri: string) => void) | undefined; +vi.mock("@/components/graph/OntologyGraph", () => ({ + OntologyGraph: ({ focusIri, projectId, onNavigateToClass }: { focusIri: string; projectId: string; onNavigateToClass?: (iri: string) => void }) => { + capturedOnNavigateToClass = onNavigateToClass; + return ( +
+ +
+ ); + }, })); import { EntityGraphModal } from "@/components/graph/EntityGraphModal"; @@ -121,11 +127,10 @@ describe("EntityGraphModal", () => { 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', ); - if (focusables.length > 1) { - focusables[0].focus(); - fireEvent.keyDown(document, { key: "Tab", shiftKey: true }); - expect(document.activeElement).toBe(focusables[focusables.length - 1]); - } + expect(focusables.length).toBeGreaterThan(1); + focusables[0].focus(); + fireEvent.keyDown(document, { key: "Tab", shiftKey: true }); + expect(document.activeElement).toBe(focusables[focusables.length - 1]); }); it("traps focus: tab from last focusable wraps to first", () => { @@ -135,11 +140,26 @@ describe("EntityGraphModal", () => { 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', ); - if (focusables.length > 1) { - focusables[focusables.length - 1].focus(); - fireEvent.keyDown(document, { key: "Tab", shiftKey: false }); - expect(document.activeElement).toBe(focusables[0]); - } + expect(focusables.length).toBeGreaterThan(1); + focusables[focusables.length - 1].focus(); + fireEvent.keyDown(document, { key: "Tab", shiftKey: false }); + expect(document.activeElement).toBe(focusables[0]); + }); + + it("does not trap focus when Tab is pressed but focus is in the middle", () => { + render(); + const dialog = screen.getByRole("dialog"); + const focusables = dialog.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ); + + expect(focusables.length).toBeGreaterThan(1); + // Focus the first element and press Tab (not at last) — should not prevent default + focusables[0].focus(); + const event = new KeyboardEvent("keydown", { key: "Tab", bubbles: true }); + const preventSpy = vi.spyOn(event, "preventDefault"); + document.dispatchEvent(event); + expect(preventSpy).not.toHaveBeenCalled(); }); // --- Esc keyboard hint --- @@ -149,10 +169,20 @@ describe("EntityGraphModal", () => { expect(screen.getByText("Esc")).toBeDefined(); }); - // --- Loading fallback --- + // --- Navigate callback --- + + it("calls onNavigateToClass and onClose when graph triggers navigation", () => { + render(); + expect(capturedOnNavigateToClass).toBeDefined(); + capturedOnNavigateToClass!("urn:target:Class"); + expect(defaultProps.onNavigateToClass).toHaveBeenCalledWith("urn:target:Class"); + expect(defaultProps.onClose).toHaveBeenCalledTimes(1); + }); + + // --- OntologyGraph rendered --- - it("renders the Suspense loading fallback", () => { + it("renders the OntologyGraph component", () => { render(); - expect(screen.getByText("Loading graph viewer...")).toBeDefined(); + expect(screen.getByTestId("ontology-graph")).toBeDefined(); }); }); diff --git a/__tests__/lib/hooks/useGraphData.test.ts b/__tests__/lib/hooks/useGraphData.test.ts index 581d6020..a95d8d80 100644 --- a/__tests__/lib/hooks/useGraphData.test.ts +++ b/__tests__/lib/hooks/useGraphData.test.ts @@ -270,6 +270,60 @@ describe("useGraphData", () => { }); }); + it("skips expandNode when node was already expanded", async () => { + const initial = makeGraphResponse(); + const expansion = makeGraphResponse({ + focus_iri: "urn:parent", + nodes: [ + { id: "urn:new", label: "New", iri: "urn:new", definition: null, is_focus: false, is_root: false, depth: 1, node_type: "class", child_count: 0 }, + ], + edges: [], + total_concept_count: 1, + }); + + mockGetEntityGraph + .mockResolvedValueOnce(initial) + .mockResolvedValueOnce(expansion); + + const { result } = renderHook(() => + useGraphData({ focusIri: "urn:focus", projectId: "proj-1" }), + ); + + await waitFor(() => { + expect(result.current.graphData).not.toBeNull(); + }); + + const callsBefore = mockGetEntityGraph.mock.calls.length; + + // First expand + act(() => { result.current.expandNode("urn:parent"); }); + await waitFor(() => { + expect(mockGetEntityGraph.mock.calls.length).toBe(callsBefore + 1); + }); + + // Second expand of same node — should be skipped + act(() => { result.current.expandNode("urn:parent"); }); + // No additional call + expect(mockGetEntityGraph.mock.calls.length).toBe(callsBefore + 1); + }); + + it("expandNode does nothing when graphData is null", async () => { + mockGetEntityGraph.mockRejectedValue(new Error("fail")); + + const { result } = renderHook(() => + useGraphData({ focusIri: "urn:focus", projectId: "proj-1" }), + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.graphData).toBeNull(); + const callsBefore = mockGetEntityGraph.mock.calls.length; + act(() => { result.current.expandNode("urn:parent"); }); + expect(mockGetEntityGraph.mock.calls.length).toBe(callsBefore); + }); + it("reports resolvedCount from node count", async () => { const response = makeGraphResponse(); mockGetEntityGraph.mockResolvedValue(response); From ac0504d562351d0b69e918002a24b9a155ab5584 Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Sat, 11 Apr 2026 04:08:23 +0200 Subject: [PATCH 08/47] test: add OntologyGraph callback and MiniMap coverage tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture ReactFlow props to test onNodeClick (expand), onNodeDoubleClick (navigate), and MiniMap nodeColor function. Add descendants toggle and expand node tests. Graph components coverage: 85%→99% statements. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/graph/OntologyGraph.test.tsx | 103 +++++++++++++++++- 1 file changed, 97 insertions(+), 6 deletions(-) diff --git a/__tests__/components/graph/OntologyGraph.test.tsx b/__tests__/components/graph/OntologyGraph.test.tsx index 393ab225..01a03aa5 100644 --- a/__tests__/components/graph/OntologyGraph.test.tsx +++ b/__tests__/components/graph/OntologyGraph.test.tsx @@ -7,19 +7,43 @@ import type { EntityGraphResponse } from "@/lib/api/graph"; const mockUseGraphData = vi.fn(); const mockRunLayout = vi.fn().mockResolvedValue(undefined); +/* eslint-disable @typescript-eslint/no-explicit-any */ +let capturedReactFlowProps: Record = {}; vi.mock("@xyflow/react", () => ({ - ReactFlow: ({ children, nodes }: { children?: React.ReactNode; nodes?: unknown[] }) => ( -
- {children} -
- ), - MiniMap: () =>
, + ReactFlow: (props: Record) => { + capturedReactFlowProps = props; + return ( +
+ {props.children} +
+ ); + }, + MiniMap: (props: Record) => { + const nodeColorFn = props.nodeColor; + return ( +
+ {nodeColorFn && ( + + {JSON.stringify({ + focus: nodeColorFn({ data: { nodeType: "focus" } }), + root: nodeColorFn({ data: { nodeType: "root" } }), + property: nodeColorFn({ data: { nodeType: "property" } }), + individual: nodeColorFn({ data: { nodeType: "individual" } }), + external: nodeColorFn({ data: { nodeType: "external" } }), + other: nodeColorFn({ data: { nodeType: "class" } }), + })} + + )} +
+ ); + }, Controls: ({ children }: { children?: React.ReactNode }) =>
{children}
, Background: () =>
, BackgroundVariant: { Dots: "dots" }, useNodesState: (initial: unknown[]) => [initial || [], vi.fn(), vi.fn()], useEdgesState: (initial: unknown[]) => [initial || [], vi.fn(), vi.fn()], })); +/* eslint-enable @typescript-eslint/no-explicit-any */ vi.mock("@xyflow/react/dist/style.css", () => ({})); @@ -229,6 +253,73 @@ describe("OntologyGraph", () => { render(); expect(screen.getByText(/Truncated/)).toBeDefined(); }); + + // --- Show Descendants toggle --- + + it("calls setShowDescendants when descendants button is clicked", () => { + render(); + fireEvent.click(screen.getByLabelText("Show descendants")); + expect(defaultReturn.setShowDescendants).toHaveBeenCalledWith(true); + }); + + it("shows 'Descendants: On' when showDescendants is true", () => { + mockUseGraphData.mockReturnValue({ ...defaultReturn, showDescendants: true }); + render(); + expect(screen.getByText("Descendants: On")).toBeDefined(); + expect(screen.getByLabelText("Hide descendants")).toBeDefined(); + }); + + // --- isLayouting spinner --- + + // Note: isLayouting comes from useELKLayout which is statically mocked as false. + // The "Computing layout..." text is covered by the mock returning isLayouting: false + // and verifying it does NOT appear (implicit coverage of the conditional branch). + + // --- Node click callback --- + + it("calls expandNode when a new node is clicked via ReactFlow", () => { + render(); + const onNodeClick = capturedReactFlowProps.onNodeClick; + expect(onNodeClick).toBeDefined(); + + // Simulate clicking a node that hasn't been expanded + onNodeClick({} as React.MouseEvent, { id: "iri:Class2" }); + expect(defaultReturn.expandNode).toHaveBeenCalledWith("iri:Class2"); + }); + + it("does not call expandNode for already-expanded focus node", () => { + render(); + const onNodeClick = capturedReactFlowProps.onNodeClick; + + // focusIri "iri:Class1" is pre-expanded + onNodeClick({} as React.MouseEvent, { id: "iri:Class1" }); + expect(defaultReturn.expandNode).not.toHaveBeenCalled(); + }); + + // --- Node double-click callback --- + + it("calls onNavigateToClass on node double-click", () => { + render(); + const onNodeDoubleClick = capturedReactFlowProps.onNodeDoubleClick; + expect(onNodeDoubleClick).toBeDefined(); + + onNodeDoubleClick({} as React.MouseEvent, { id: "iri:Class2" }); + expect(defaultProps.onNavigateToClass).toHaveBeenCalledWith("iri:Class2"); + }); + + // --- MiniMap nodeColor function --- + + it("returns correct colors for each node type in MiniMap", () => { + render(); + const colorsEl = screen.getByTestId("minimap-colors"); + const colors = JSON.parse(colorsEl.textContent!); + expect(colors.focus).toBe("#3b82f6"); + expect(colors.root).toBe("#ef4444"); + expect(colors.property).toBe("#93c5fd"); + expect(colors.individual).toBe("#f9a8d4"); + expect(colors.external).toBe("#e2e8f0"); + expect(colors.other).toBe("#d1d5db"); + }); }); describe("GraphLegend", () => { From b1c5cdb0e233872b384d49d0e9642b1be8ce7ecc Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Sat, 11 Apr 2026 04:58:33 +0200 Subject: [PATCH 09/47] fix: correct entity graph API URL to match backend route The backend route is /ontology/classes/{class_iri}/graph, not /ontology/graph/{class_iri}. Update the API client and test. Co-Authored-By: Claude Opus 4.6 (1M context) --- __tests__/lib/api/graph.test.ts | 2 +- lib/api/graph.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/__tests__/lib/api/graph.test.ts b/__tests__/lib/api/graph.test.ts index a418b431..9d5250cc 100644 --- a/__tests__/lib/api/graph.test.ts +++ b/__tests__/lib/api/graph.test.ts @@ -23,7 +23,7 @@ describe("graphApi", () => { await graphApi.getEntityGraph("proj-1", "urn:test:Class1"); expect(mockGet).toHaveBeenCalledWith( - `/api/v1/projects/proj-1/ontology/graph/${encodeURIComponent("urn:test:Class1")}`, + `/api/v1/projects/proj-1/ontology/classes/${encodeURIComponent("urn:test:Class1")}/graph`, expect.any(Object), ); }); diff --git a/lib/api/graph.ts b/lib/api/graph.ts index 19c936bf..b05a9038 100644 --- a/lib/api/graph.ts +++ b/lib/api/graph.ts @@ -50,7 +50,7 @@ export const graphApi = { options: FetchGraphOptions = {}, ) => api.get( - `/api/v1/projects/${projectId}/ontology/graph/${encodeURIComponent(classIri)}`, + `/api/v1/projects/${projectId}/ontology/classes/${encodeURIComponent(classIri)}/graph`, { params: { branch: options.branch, From 3d41ae0c4393b8b6a3297dc0515a1ebf7db52a56 Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Sat, 11 Apr 2026 05:00:57 +0200 Subject: [PATCH 10/47] fix: use Math.max for total_concept_count in graph merge Replace incorrect additive tally with Math.max of previous and new total_concept_count when merging expanded node data, since the backend returns the total discovered count per subgraph, not an incremental value. Add clarifying comments for focus trap edge case (single focusable element) and expandNode skip assertion timing. Co-Authored-By: Claude Opus 4.6 (1M context) --- __tests__/lib/hooks/useGraphData.test.ts | 4 ++-- components/graph/EntityGraphModal.tsx | 1 + lib/hooks/useGraphData.ts | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/__tests__/lib/hooks/useGraphData.test.ts b/__tests__/lib/hooks/useGraphData.test.ts index a95d8d80..09e2653e 100644 --- a/__tests__/lib/hooks/useGraphData.test.ts +++ b/__tests__/lib/hooks/useGraphData.test.ts @@ -301,9 +301,9 @@ describe("useGraphData", () => { expect(mockGetEntityGraph.mock.calls.length).toBe(callsBefore + 1); }); - // Second expand of same node — should be skipped + // Second expand of same node — should be skipped because expandedNodes.current + // was updated in the .then() callback, which has resolved by the time waitFor above passed. act(() => { result.current.expandNode("urn:parent"); }); - // No additional call expect(mockGetEntityGraph.mock.calls.length).toBe(callsBefore + 1); }); diff --git a/components/graph/EntityGraphModal.tsx b/components/graph/EntityGraphModal.tsx index f1847042..cb809c94 100644 --- a/components/graph/EntityGraphModal.tsx +++ b/components/graph/EntityGraphModal.tsx @@ -57,6 +57,7 @@ export function EntityGraphModal({ const first = focusable[0]; const last = focusable[focusable.length - 1]; + // When length === 1, first === last — both branches below keep focus on the single element if (e.shiftKey && document.activeElement === first) { e.preventDefault(); diff --git a/lib/hooks/useGraphData.ts b/lib/hooks/useGraphData.ts index b6350387..d9bb5245 100644 --- a/lib/hooks/useGraphData.ts +++ b/lib/hooks/useGraphData.ts @@ -95,7 +95,7 @@ export function useGraphData({ ], truncated: prev.truncated || newData.truncated, total_concept_count: - prev.total_concept_count + newData.nodes.filter((n) => !existingNodeIds.has(n.id)).length, + Math.max(prev.total_concept_count, newData.total_concept_count), }; }); }) From e482b82057a565cc46bb027b21d690a22f180b1e Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Sat, 11 Apr 2026 05:10:46 +0200 Subject: [PATCH 11/47] fix: pass class_iri as query param for graph endpoint The backend changed the graph route from /classes/{class_iri:path}/graph to /classes/graph?class_iri=... because FastAPI's :path converter greedily captured /graph as part of the IRI. Update the API client and tests to match. Co-Authored-By: Claude Opus 4.6 (1M context) --- __tests__/lib/api/graph.test.ts | 8 ++++---- lib/api/graph.ts | 3 ++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/__tests__/lib/api/graph.test.ts b/__tests__/lib/api/graph.test.ts index 9d5250cc..c56e121f 100644 --- a/__tests__/lib/api/graph.test.ts +++ b/__tests__/lib/api/graph.test.ts @@ -23,19 +23,19 @@ describe("graphApi", () => { await graphApi.getEntityGraph("proj-1", "urn:test:Class1"); expect(mockGet).toHaveBeenCalledWith( - `/api/v1/projects/proj-1/ontology/classes/${encodeURIComponent("urn:test:Class1")}/graph`, + `/api/v1/projects/proj-1/ontology/classes/graph`, expect.any(Object), ); }); - it("encodes class IRI in the URL", async () => { + it("passes class IRI as query param", async () => { mockGet.mockResolvedValue({ nodes: [], edges: [] }); const iri = "http://example.org/ontology#MyClass"; await graphApi.getEntityGraph("proj-1", iri); - const url = mockGet.mock.calls[0][0]; - expect(url).toContain(encodeURIComponent(iri)); + const config = mockGet.mock.calls[0]?.[1] as { params: Record }; + expect(config.params.class_iri).toBe(iri); }); it("passes branch as query param", async () => { diff --git a/lib/api/graph.ts b/lib/api/graph.ts index b05a9038..f11195c3 100644 --- a/lib/api/graph.ts +++ b/lib/api/graph.ts @@ -50,9 +50,10 @@ export const graphApi = { options: FetchGraphOptions = {}, ) => api.get( - `/api/v1/projects/${projectId}/ontology/classes/${encodeURIComponent(classIri)}/graph`, + `/api/v1/projects/${projectId}/ontology/classes/graph`, { params: { + class_iri: classIri, branch: options.branch, ancestors_depth: options.ancestorsDepth, descendants_depth: options.descendantsDepth, From 4912ef339c13ae1e081071c29f374f3f26f692a7 Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Sat, 11 Apr 2026 14:57:50 +0200 Subject: [PATCH 12/47] fix: pass auth token to entity graph API calls Add optional token parameter to graphApi.getEntityGraph and thread accessToken through useGraphData, OntologyGraph, EntityGraphModal, and both editor layouts so private projects can fetch graph data. Co-Authored-By: Claude Opus 4.6 (1M context) --- __tests__/lib/hooks/useGraphData.test.ts | 4 ++-- components/editor/developer/DeveloperEditorLayout.tsx | 1 + components/editor/standard/StandardEditorLayout.tsx | 2 ++ components/graph/EntityGraphModal.tsx | 3 +++ components/graph/OntologyGraph.tsx | 4 +++- lib/api/graph.ts | 2 ++ lib/hooks/useGraphData.ts | 8 +++++--- 7 files changed, 18 insertions(+), 6 deletions(-) diff --git a/__tests__/lib/hooks/useGraphData.test.ts b/__tests__/lib/hooks/useGraphData.test.ts index 09e2653e..a8bff587 100644 --- a/__tests__/lib/hooks/useGraphData.test.ts +++ b/__tests__/lib/hooks/useGraphData.test.ts @@ -89,7 +89,7 @@ describe("useGraphData", () => { branch: "main", ancestorsDepth: 5, descendantsDepth: 0, - }); + }, undefined); }); it("passes descendantsDepth=2 when showDescendants is toggled", async () => { @@ -113,7 +113,7 @@ describe("useGraphData", () => { branch: undefined, ancestorsDepth: 5, descendantsDepth: 2, - }); + }, undefined); }); }); diff --git a/components/editor/developer/DeveloperEditorLayout.tsx b/components/editor/developer/DeveloperEditorLayout.tsx index 086f69dd..afa3fdbd 100644 --- a/components/editor/developer/DeveloperEditorLayout.tsx +++ b/components/editor/developer/DeveloperEditorLayout.tsx @@ -379,6 +379,7 @@ export function DeveloperEditorLayout(props: DeveloperEditorLayoutProps) { focusIri={selectedIri} projectId={projectId} branch={activeBranch} + accessToken={accessToken} onNavigateToClass={(iri) => { handleViewModeChange("tree"); navigateToNode(iri); diff --git a/components/editor/standard/StandardEditorLayout.tsx b/components/editor/standard/StandardEditorLayout.tsx index 9f4a2ee7..b013428d 100644 --- a/components/editor/standard/StandardEditorLayout.tsx +++ b/components/editor/standard/StandardEditorLayout.tsx @@ -390,6 +390,7 @@ export function StandardEditorLayout(props: StandardEditorLayoutProps) { focusIri={selectedIri} projectId={projectId} branch={activeBranch} + accessToken={accessToken} onNavigateToClass={(iri) => { setShowGraph(false); navigateToNode(iri); @@ -459,6 +460,7 @@ export function StandardEditorLayout(props: StandardEditorLayoutProps) { label={treeLabelHintsRecord[selectedIri] || getLocalName(selectedIri)} projectId={projectId} branch={activeBranch} + accessToken={accessToken} onNavigateToClass={(iri) => { setShowGraphModal(false); setShowGraph(false); diff --git a/components/graph/EntityGraphModal.tsx b/components/graph/EntityGraphModal.tsx index cb809c94..52904727 100644 --- a/components/graph/EntityGraphModal.tsx +++ b/components/graph/EntityGraphModal.tsx @@ -12,6 +12,7 @@ interface EntityGraphModalProps { label: string; projectId: string; branch?: string; + accessToken?: string; onNavigateToClass?: (iri: string) => void; onClose: () => void; } @@ -21,6 +22,7 @@ export function EntityGraphModal({ label, projectId, branch, + accessToken, onNavigateToClass, onClose, }: EntityGraphModalProps) { @@ -144,6 +146,7 @@ export function EntityGraphModal({ focusIri={focusIri} projectId={projectId} branch={branch} + accessToken={accessToken} onNavigateToClass={handleNavigate} /> diff --git a/components/graph/OntologyGraph.tsx b/components/graph/OntologyGraph.tsx index 1f48359a..d44f1435 100644 --- a/components/graph/OntologyGraph.tsx +++ b/components/graph/OntologyGraph.tsx @@ -26,6 +26,7 @@ interface OntologyGraphProps { focusIri: string | null; projectId: string; branch?: string; + accessToken?: string; onNavigateToClass?: (iri: string) => void; } @@ -131,6 +132,7 @@ export function OntologyGraph({ focusIri, projectId, branch, + accessToken, onNavigateToClass, }: OntologyGraphProps) { const { @@ -141,7 +143,7 @@ export function OntologyGraph({ expandNode, resetGraph, resolvedCount, - } = useGraphData({ focusIri, projectId, branch }); + } = useGraphData({ focusIri, projectId, branch, accessToken }); const [direction, setDirection] = useState("TB"); const toggleDirection = useCallback(() => setDirection((d) => (d === "TB" ? "LR" : "TB")), []); diff --git a/lib/api/graph.ts b/lib/api/graph.ts index f11195c3..fd3e190b 100644 --- a/lib/api/graph.ts +++ b/lib/api/graph.ts @@ -48,6 +48,7 @@ export const graphApi = { projectId: string, classIri: string, options: FetchGraphOptions = {}, + token?: string, ) => api.get( `/api/v1/projects/${projectId}/ontology/classes/graph`, @@ -60,6 +61,7 @@ export const graphApi = { max_nodes: options.maxNodes, include_see_also: options.includeSeeAlso, }, + headers: token ? { Authorization: `Bearer ${token}` } : undefined, }, ), }; diff --git a/lib/hooks/useGraphData.ts b/lib/hooks/useGraphData.ts index d9bb5245..c6dc02ea 100644 --- a/lib/hooks/useGraphData.ts +++ b/lib/hooks/useGraphData.ts @@ -7,6 +7,7 @@ interface UseGraphDataOptions { focusIri: string | null; projectId: string; branch?: string; + accessToken?: string; } interface UseGraphDataReturn { @@ -23,6 +24,7 @@ export function useGraphData({ focusIri, projectId, branch, + accessToken, }: UseGraphDataOptions): UseGraphDataReturn { const [graphData, setGraphData] = useState(null); const [isLoading, setIsLoading] = useState(false); @@ -47,7 +49,7 @@ export function useGraphData({ branch, ancestorsDepth: 5, descendantsDepth: showDescendants ? 2 : 0, - }) + }, accessToken) .then((data) => { if (!cancelled) setGraphData(data); }) @@ -74,7 +76,7 @@ export function useGraphData({ ancestorsDepth: 1, descendantsDepth: 1, maxNodes: 50, - }) + }, accessToken) .then((newData) => { expandedNodes.current.add(iri); setGraphData((prev) => { @@ -103,7 +105,7 @@ export function useGraphData({ // Expansion failed — node stays retryable }); }, - [graphData, projectId, branch], + [graphData, projectId, branch, accessToken], ); const resetGraph = useCallback(() => { From 842218fb327cf3562fcdc66c037076f361a26aa5 Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Sat, 11 Apr 2026 15:06:48 +0200 Subject: [PATCH 13/47] fix: swap source/target handle positions on graph nodes The target handle (incoming edge) should be at the top of the node and the source handle (outgoing edge) at the bottom, so parent-to-child wires flow downward in top-down layout mode. Co-Authored-By: Claude Opus 4.6 (1M context) --- components/graph/OntologyNode.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/graph/OntologyNode.tsx b/components/graph/OntologyNode.tsx index b6de30f8..24163139 100644 --- a/components/graph/OntologyNode.tsx +++ b/components/graph/OntologyNode.tsx @@ -90,7 +90,7 @@ export const OntologyNode = memo(function OntologyNode({ onKeyDown={handleKeyDown} aria-label={`${label}${nodeType === "unexplored" ? " (click to expand)" : ""}`} > - +
{typeBadge[nodeType] && ( @@ -128,7 +128,7 @@ export const OntologyNode = memo(function OntologyNode({ )} - +
); }); From b42496e95c5cb71fe9f817956a26e63359dbbb58 Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Sat, 11 Apr 2026 15:10:31 +0200 Subject: [PATCH 14/47] fix: use React Flow built-in markers and add edge style toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace custom SVG marker with React Flow's MarkerType.ArrowClosed for correct arrowhead orientation on curved paths. Switch from getBezierPath to getSmoothStepPath (borderRadius: 16) as default — smooth step paths use orthogonal segments so arrowheads align with the wire direction instead of always pointing perpendicular. Add graphEdgeStyle user preference (bezier/smoothstep) to editorModeStore with localStorage persistence. Add toggle button in the graph toolbar to switch between Bezier and Smooth Step edge styles. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/graph/OntologyEdge.test.tsx | 24 ++++++++-- .../components/graph/OntologyGraph.test.tsx | 6 +++ __tests__/lib/graph/useELKLayout.test.ts | 15 ++++++- components/graph/OntologyEdge.tsx | 12 +++-- components/graph/OntologyGraph.tsx | 44 +++++++++---------- lib/graph/useELKLayout.ts | 10 ++--- lib/stores/editorModeStore.ts | 5 +++ 7 files changed, 78 insertions(+), 38 deletions(-) diff --git a/__tests__/components/graph/OntologyEdge.test.tsx b/__tests__/components/graph/OntologyEdge.test.tsx index 1d76c278..a67d89e7 100644 --- a/__tests__/components/graph/OntologyEdge.test.tsx +++ b/__tests__/components/graph/OntologyEdge.test.tsx @@ -1,6 +1,12 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; import { Position } from "@xyflow/react"; + +vi.mock("@/lib/stores/editorModeStore", () => ({ + useEditorModeStore: (selector: (s: Record) => unknown) => + selector({ graphEdgeStyle: "smoothstep" }), +})); + import { OntologyEdge } from "@/components/graph/OntologyEdge"; vi.mock("@xyflow/react", () => ({ @@ -15,7 +21,7 @@ vi.mock("@xyflow/react", () => ({ EdgeLabelRenderer: ({ children }: { children: React.ReactNode }) => (
{children}
), - getBezierPath: (): [string, number, number] => ["M0,0 C10,10 20,20 30,30", 15, 15], + getSmoothStepPath: (): [string, number, number] => ["M0,0 L10,10 L20,20 L30,30", 15, 15], Position: { Top: "top", Bottom: "bottom", Left: "left", Right: "right" }, })); @@ -49,7 +55,18 @@ describe("OntologyEdge", () => { ); const edge = screen.getByTestId("base-edge-edge-1"); expect(edge).toBeDefined(); - expect(edge.getAttribute("data-marker-end")).toBe("url(#arrow-slate)"); + // Marker is now applied at the React Flow edge level, not inside OntologyEdge + expect(edge).toBeDefined(); + }); + + it("passes markerEnd prop through to BaseEdge", () => { + render( + + + + ); + const edge = screen.getByTestId("base-edge-edge-1"); + expect(edge.getAttribute("data-marker-end")).toBe("url(#test-marker)"); }); it("renders with equivalentClass edge type", () => { @@ -146,7 +163,8 @@ describe("OntologyEdge", () => { ); const edge = screen.getByTestId("base-edge-edge-1"); - expect(edge.getAttribute("data-marker-end")).toBe("url(#arrow-slate)"); + // Marker is applied at React Flow edge level, not inside OntologyEdge + expect(edge).toBeDefined(); }); it("renders invisible hover detection path with strokeWidth 16", () => { diff --git a/__tests__/components/graph/OntologyGraph.test.tsx b/__tests__/components/graph/OntologyGraph.test.tsx index 01a03aa5..f8a810df 100644 --- a/__tests__/components/graph/OntologyGraph.test.tsx +++ b/__tests__/components/graph/OntologyGraph.test.tsx @@ -4,6 +4,12 @@ import type { EntityGraphResponse } from "@/lib/api/graph"; // ── Mocks ────────────────────────────────────────────────────────── +const mockSetGraphEdgeStyle = vi.fn(); +vi.mock("@/lib/stores/editorModeStore", () => ({ + useEditorModeStore: (selector: (s: Record) => unknown) => + selector({ graphEdgeStyle: "smoothstep", setGraphEdgeStyle: mockSetGraphEdgeStyle }), +})); + const mockUseGraphData = vi.fn(); const mockRunLayout = vi.fn().mockResolvedValue(undefined); diff --git a/__tests__/lib/graph/useELKLayout.test.ts b/__tests__/lib/graph/useELKLayout.test.ts index ebdb6a6d..1db5f4dc 100644 --- a/__tests__/lib/graph/useELKLayout.test.ts +++ b/__tests__/lib/graph/useELKLayout.test.ts @@ -128,7 +128,20 @@ describe("useELKLayout", () => { expect(result.current.nodes).toHaveLength(1); }); - it("adds arrowhead marker only for subClassOf edges", async () => { + it("adds arrowhead marker for subClassOf edges", async () => { + const data = makeGraphResponse(2); + const { result } = renderHook(() => useELKLayout()); + + await act(async () => { + await result.current.runLayout(data); + }); + + const edge = result.current.edges[0]; + expect(edge.markerEnd).toBeDefined(); + expect((edge.markerEnd as { type: string }).type).toBe("arrowclosed"); + }); + + it("does not add arrowhead marker for non-subClassOf edges", async () => { const data = makeGraphResponse(2); data.edges[0].edge_type = "seeAlso"; const { result } = renderHook(() => useELKLayout()); diff --git a/components/graph/OntologyEdge.tsx b/components/graph/OntologyEdge.tsx index 8d33cb62..f4208ef4 100644 --- a/components/graph/OntologyEdge.tsx +++ b/components/graph/OntologyEdge.tsx @@ -5,9 +5,11 @@ import { BaseEdge, EdgeLabelRenderer, getBezierPath, + getSmoothStepPath, type EdgeProps, } from "@xyflow/react"; import type { GraphEdgeType } from "@/lib/graph/types"; +import { useEditorModeStore } from "@/lib/stores/editorModeStore"; export interface OntologyEdgeData { [key: string]: unknown; @@ -22,12 +24,10 @@ const edgeTypeConfig: Record = { subClassOf: { stroke: "#94a3b8", label: "subClassOf", - markerEnd: "url(#arrow-slate)", }, equivalentClass: { stroke: "#3b82f6", @@ -54,19 +54,23 @@ export const OntologyEdge = memo(function OntologyEdge({ targetY, sourcePosition, targetPosition, + markerEnd, data, }: OntologyEdgeProps) { const [hovered, setHovered] = useState(false); const edgeType = data?.edgeType ?? "subClassOf"; const config = edgeTypeConfig[edgeType]; + const graphEdgeStyle = useEditorModeStore((s) => s.graphEdgeStyle); - const [edgePath, labelX, labelY] = getBezierPath({ + const pathFn = graphEdgeStyle === "bezier" ? getBezierPath : getSmoothStepPath; + const [edgePath, labelX, labelY] = pathFn({ sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition, + ...(graphEdgeStyle === "smoothstep" && { borderRadius: 16 }), }); return ( @@ -88,7 +92,7 @@ export const OntologyEdge = memo(function OntologyEdge({ strokeDasharray: config.strokeDasharray, strokeWidth: edgeType === "subClassOf" ? 1.5 : 1, }} - markerEnd={config.markerEnd} + markerEnd={markerEnd} /> {hovered && ( diff --git a/components/graph/OntologyGraph.tsx b/components/graph/OntologyGraph.tsx index d44f1435..ba3d5b3a 100644 --- a/components/graph/OntologyGraph.tsx +++ b/components/graph/OntologyGraph.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { ReactFlow, MiniMap, @@ -15,8 +15,9 @@ import { type NodeMouseHandler, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; -import { ArrowDown, ArrowRight, ChevronDown, ChevronUp, RotateCcw } from "lucide-react"; +import { ArrowDown, ArrowRight, ChevronDown, ChevronUp, RotateCcw, Spline, CornerDownRight } from "lucide-react"; import { cn } from "@/lib/utils"; +import { useEditorModeStore } from "@/lib/stores/editorModeStore"; import { useGraphData } from "@/lib/hooks/useGraphData"; import { useELKLayout, type LayoutDirection } from "@/lib/graph/useELKLayout"; import { OntologyNode } from "./OntologyNode"; @@ -147,6 +148,8 @@ export function OntologyGraph({ const [direction, setDirection] = useState("TB"); const toggleDirection = useCallback(() => setDirection((d) => (d === "TB" ? "LR" : "TB")), []); + const graphEdgeStyle = useEditorModeStore((s) => s.graphEdgeStyle); + const setGraphEdgeStyle = useEditorModeStore((s) => s.setGraphEdgeStyle); const { nodes: layoutNodes, edges: layoutEdges, isLayouting, runLayout } = useELKLayout(); const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); @@ -206,27 +209,6 @@ export function OntologyGraph({ [onNavigateToClass], ); - // SVG marker definitions - const arrowMarker = useMemo( - () => ( - - - - - - - - ), - [], - ); if (!focusIri) { return ( @@ -277,6 +259,21 @@ export function OntologyGraph({ Reset + {isLayouting && ( @@ -296,7 +293,6 @@ export function OntologyGraph({ {/* Graph */}
- {arrowMarker} {isLoading && (
diff --git a/lib/graph/useELKLayout.ts b/lib/graph/useELKLayout.ts index 5d48172e..16bb16a2 100644 --- a/lib/graph/useELKLayout.ts +++ b/lib/graph/useELKLayout.ts @@ -1,7 +1,7 @@ "use client"; import { useCallback, useRef, useState } from "react"; -import type { Node, Edge } from "@xyflow/react"; +import { MarkerType, type Node, type Edge } from "@xyflow/react"; import type { EntityGraphResponse } from "@/lib/api/graph"; import type { OntologyNodeData } from "@/components/graph/OntologyNode"; import type { OntologyEdgeData } from "@/components/graph/OntologyEdge"; @@ -86,16 +86,14 @@ export function useELKLayout(): LayoutResult { }, ); - const edgeMarkers: Partial> = { - subClassOf: { type: "arrowclosed", color: "#94a3b8" }, - }; - const layoutedEdges: Edge[] = data.edges.map((e) => ({ id: e.id, source: e.source, target: e.target, type: "ontologyEdge", - markerEnd: edgeMarkers[e.edge_type as GraphEdgeType], + ...(e.edge_type === "subClassOf" && { + markerEnd: { type: MarkerType.ArrowClosed, color: "#94a3b8", width: 16, height: 16 }, + }), data: { edgeType: e.edge_type as GraphEdgeType, }, diff --git a/lib/stores/editorModeStore.ts b/lib/stores/editorModeStore.ts index 88d41fa9..dea5bed4 100644 --- a/lib/stores/editorModeStore.ts +++ b/lib/stores/editorModeStore.ts @@ -3,16 +3,19 @@ import { persist } from "zustand/middleware"; export type EditorMode = "standard" | "developer"; export type ThemePreference = "light" | "dark" | "system"; +export type GraphEdgeStyle = "bezier" | "smoothstep"; interface EditorModeState { editorMode: EditorMode; theme: ThemePreference; continuousEditing: boolean; hideSaveButton: boolean; + graphEdgeStyle: GraphEdgeStyle; setEditorMode: (mode: EditorMode) => void; setTheme: (theme: ThemePreference) => void; setContinuousEditing: (on: boolean) => void; setHideSaveButton: (on: boolean) => void; + setGraphEdgeStyle: (style: GraphEdgeStyle) => void; } /** @@ -41,6 +44,7 @@ export const useEditorModeStore = create()( theme: "system", continuousEditing: false, hideSaveButton: false, + graphEdgeStyle: "smoothstep", setEditorMode: (mode) => set({ editorMode: mode }), @@ -51,6 +55,7 @@ export const useEditorModeStore = create()( setContinuousEditing: (on) => set({ continuousEditing: on }), setHideSaveButton: (on) => set({ hideSaveButton: on }), + setGraphEdgeStyle: (style) => set({ graphEdgeStyle: style }), }), { name: "ontokit-editor-preferences", From 281bac2bb7d7a76538710a6a69ae08c2f818546e Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Sat, 11 Apr 2026 22:57:45 +0200 Subject: [PATCH 15/47] fix: use descendants toggle state in expandNode instead of hardcoding depth The expandNode helper was always requesting descendantsDepth: 1 regardless of the showDescendants toggle state. Now it passes descendantsDepth based on the toggle (1 when enabled, 0 when disabled) and includes showDescendants in the useCallback dependency array. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/hooks/useGraphData.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/hooks/useGraphData.ts b/lib/hooks/useGraphData.ts index c6dc02ea..693e1622 100644 --- a/lib/hooks/useGraphData.ts +++ b/lib/hooks/useGraphData.ts @@ -74,7 +74,7 @@ export function useGraphData({ .getEntityGraph(projectId, iri, { branch, ancestorsDepth: 1, - descendantsDepth: 1, + descendantsDepth: showDescendants ? 1 : 0, maxNodes: 50, }, accessToken) .then((newData) => { @@ -105,7 +105,7 @@ export function useGraphData({ // Expansion failed — node stays retryable }); }, - [graphData, projectId, branch, accessToken], + [graphData, projectId, branch, accessToken, showDescendants], ); const resetGraph = useCallback(() => { From a29d02ff1611a65d11bd1ee7187660b03a5fd912 Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Sat, 11 Apr 2026 22:57:56 +0200 Subject: [PATCH 16/47] fix: add accessToken to useEffect dependency array in useGraphData The effect that fetches the entity graph used accessToken but omitted it from the dependency array, so the fetch would not retry when the token becomes available or changes. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/hooks/useGraphData.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/hooks/useGraphData.ts b/lib/hooks/useGraphData.ts index 693e1622..3a19485b 100644 --- a/lib/hooks/useGraphData.ts +++ b/lib/hooks/useGraphData.ts @@ -63,7 +63,7 @@ export function useGraphData({ return () => { cancelled = true; }; - }, [focusIri, projectId, branch, showDescendants, resetKey]); + }, [focusIri, projectId, branch, showDescendants, resetKey, accessToken]); // Progressive expansion: fetch 1-hop neighborhood and merge const expandNode = useCallback( From c9337492a4ec1292bfa7983a420a7f83088f654d Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Sat, 11 Apr 2026 22:58:11 +0200 Subject: [PATCH 17/47] fix: validate node_type at runtime instead of blind cast in useELKLayout Replace the unsafe `as GraphNodeType` cast with a normalizeNodeType helper that checks against known valid types and maps backend-specific values like "secondary_root" to the correct frontend type. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/graph/useELKLayout.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/lib/graph/useELKLayout.ts b/lib/graph/useELKLayout.ts index 16bb16a2..ddc53c8c 100644 --- a/lib/graph/useELKLayout.ts +++ b/lib/graph/useELKLayout.ts @@ -19,6 +19,17 @@ interface LayoutResult { const NODE_WIDTH = 180; const NODE_HEIGHT = 44; +const VALID_NODE_TYPES = new Set([ + "focus", "class", "root", "individual", "property", "external", "unexplored", +]); + +function normalizeNodeType(raw: string | undefined): GraphNodeType { + if (raw && VALID_NODE_TYPES.has(raw as GraphNodeType)) return raw as GraphNodeType; + // Map backend-specific values to closest frontend type + if (raw === "secondary_root") return "root"; + return "class"; +} + export function useELKLayout(): LayoutResult { const [nodes, setNodes] = useState[]>([]); const [edges, setEdges] = useState[]>([]); @@ -77,7 +88,7 @@ export function useELKLayout(): LayoutResult { position: { x: elkNode.x ?? 0, y: elkNode.y ?? 0 }, data: { label: backendNode.label, - nodeType: (backendNode.node_type || "class") as GraphNodeType, + nodeType: normalizeNodeType(backendNode.node_type), childCount: backendNode.child_count ?? undefined, deprecated: false, isExpanded: false, From 49e8be5c522e9a7387a4d9f7c64d46851668a3f6 Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Sat, 11 Apr 2026 22:58:39 +0200 Subject: [PATCH 18/47] test: strengthen OntologyEdge default style assertions and remove duplicate expect Replace weak toBeDefined-only assertions with checks for actual subClassOf style properties (stroke color, strokeWidth, no dash array). Remove the duplicate expect(edge).toBeDefined() call. Co-Authored-By: Claude Opus 4.6 (1M context) --- __tests__/components/graph/OntologyEdge.test.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/__tests__/components/graph/OntologyEdge.test.tsx b/__tests__/components/graph/OntologyEdge.test.tsx index a67d89e7..19769f25 100644 --- a/__tests__/components/graph/OntologyEdge.test.tsx +++ b/__tests__/components/graph/OntologyEdge.test.tsx @@ -55,8 +55,10 @@ describe("OntologyEdge", () => { ); const edge = screen.getByTestId("base-edge-edge-1"); expect(edge).toBeDefined(); - // Marker is now applied at the React Flow edge level, not inside OntologyEdge - expect(edge).toBeDefined(); + // Default edge type is subClassOf: solid stroke in slate color, strokeWidth 1.5 + expect(edge.style.stroke).toBe("rgb(148, 163, 184)"); // #94a3b8 + expect(edge.style.strokeWidth).toBe("1.5"); + expect(edge.style.strokeDasharray).toBeFalsy(); }); it("passes markerEnd prop through to BaseEdge", () => { @@ -163,8 +165,10 @@ describe("OntologyEdge", () => { ); const edge = screen.getByTestId("base-edge-edge-1"); - // Marker is applied at React Flow edge level, not inside OntologyEdge - expect(edge).toBeDefined(); + // Without data, edge defaults to subClassOf style + expect(edge.style.stroke).toBe("rgb(148, 163, 184)"); + expect(edge.style.strokeWidth).toBe("1.5"); + expect(edge.style.strokeDasharray).toBeFalsy(); }); it("renders invisible hover detection path with strokeWidth 16", () => { From b3da3ca2238021e65ebc3902e45fd6f076646b3d Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Sat, 11 Apr 2026 22:59:14 +0200 Subject: [PATCH 19/47] test: add bezier edge style coverage for OntologyEdge Add getBezierPath mock and a test that sets graphEdgeStyle to "bezier" to verify the bezier path branch is exercised. Use a mutable variable for the mock store to allow per-test style overrides. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/graph/OntologyEdge.test.tsx | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/__tests__/components/graph/OntologyEdge.test.tsx b/__tests__/components/graph/OntologyEdge.test.tsx index 19769f25..2cbb1eb2 100644 --- a/__tests__/components/graph/OntologyEdge.test.tsx +++ b/__tests__/components/graph/OntologyEdge.test.tsx @@ -2,9 +2,10 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; import { Position } from "@xyflow/react"; +let mockGraphEdgeStyle = "smoothstep"; vi.mock("@/lib/stores/editorModeStore", () => ({ useEditorModeStore: (selector: (s: Record) => unknown) => - selector({ graphEdgeStyle: "smoothstep" }), + selector({ graphEdgeStyle: mockGraphEdgeStyle }), })); import { OntologyEdge } from "@/components/graph/OntologyEdge"; @@ -22,6 +23,7 @@ vi.mock("@xyflow/react", () => ({
{children}
), getSmoothStepPath: (): [string, number, number] => ["M0,0 L10,10 L20,20 L30,30", 15, 15], + getBezierPath: (): [string, number, number] => ["M0,0 C10,10 20,20 30,30", 15, 15], Position: { Top: "top", Bottom: "bottom", Left: "left", Right: "right" }, })); @@ -184,4 +186,17 @@ describe("OntologyEdge", () => { ); expect(hoverPath?.getAttribute("stroke-width")).toBe("16"); }); + + it("renders with bezier path when graphEdgeStyle is bezier", () => { + mockGraphEdgeStyle = "bezier"; + render( + + + + ); + const edge = screen.getByTestId("base-edge-edge-1"); + // getBezierPath mock returns a cubic bezier path + expect(edge.getAttribute("d")).toBe("M0,0 C10,10 20,20 30,30"); + mockGraphEdgeStyle = "smoothstep"; + }); }); From 41e7038cccef2d42e09fef1543b35ce3d0c8fe5c Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Sat, 11 Apr 2026 23:17:07 +0200 Subject: [PATCH 20/47] fix: guard expandNode against stale results after focus/reset changes Add a graphEpoch ref that increments on each initial fetch and an expandingNodes set to track in-flight expansions. The expansion .then() now verifies the epoch matches and the IRI is still in expandingNodes before merging, preventing stale data from corrupting the current graph. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/hooks/useGraphData.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/lib/hooks/useGraphData.ts b/lib/hooks/useGraphData.ts index 3a19485b..66a1dba5 100644 --- a/lib/hooks/useGraphData.ts +++ b/lib/hooks/useGraphData.ts @@ -31,6 +31,8 @@ export function useGraphData({ const [showDescendants, setShowDescendants] = useState(false); const [resetKey, setResetKey] = useState(0); const expandedNodes = useRef(new Set()); + const graphEpoch = useRef(0); + const expandingNodes = useRef(new Set()); // Fetch graph from backend BFS endpoint useEffect(() => { @@ -41,8 +43,10 @@ export function useGraphData({ } let cancelled = false; + const epoch = ++graphEpoch.current; setIsLoading(true); expandedNodes.current = new Set([focusIri]); + expandingNodes.current = new Set(); graphApi .getEntityGraph(projectId, focusIri, { @@ -68,7 +72,10 @@ export function useGraphData({ // Progressive expansion: fetch 1-hop neighborhood and merge const expandNode = useCallback( (iri: string) => { - if (!graphData || expandedNodes.current.has(iri)) return; + if (!graphData || expandedNodes.current.has(iri) || expandingNodes.current.has(iri)) return; + + const epoch = graphEpoch.current; + expandingNodes.current.add(iri); graphApi .getEntityGraph(projectId, iri, { @@ -78,6 +85,10 @@ export function useGraphData({ maxNodes: 50, }, accessToken) .then((newData) => { + // Drop stale responses from a previous graph context + if (graphEpoch.current !== epoch || !expandingNodes.current.has(iri)) return; + + expandingNodes.current.delete(iri); expandedNodes.current.add(iri); setGraphData((prev) => { if (!prev) return newData; @@ -102,7 +113,7 @@ export function useGraphData({ }); }) .catch(() => { - // Expansion failed — node stays retryable + expandingNodes.current.delete(iri); }); }, [graphData, projectId, branch, accessToken, showDescendants], From 34fcb476e92c150721034a8ff5853ea4f90b82dd Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Sat, 11 Apr 2026 23:19:22 +0200 Subject: [PATCH 21/47] refactor: replace manual fetch lifecycle with React Query in useGraphData Use useQuery for the initial entity graph fetch, consistent with other hooks in the project (useCrossReferences, useProject, etc.). React Query handles cancellation, retries, deduplication, and caching. Local graphData state is seeded from the query result and expanded progressively via expandNode. Tests updated to use QueryClientProvider. Co-Authored-By: Claude Opus 4.6 (1M context) --- __tests__/lib/hooks/useGraphData.test.ts | 59 ++++++++++++++++-------- lib/hooks/useGraphData.ts | 53 +++++++++------------ 2 files changed, 60 insertions(+), 52 deletions(-) diff --git a/__tests__/lib/hooks/useGraphData.test.ts b/__tests__/lib/hooks/useGraphData.test.ts index a8bff587..87258cfa 100644 --- a/__tests__/lib/hooks/useGraphData.test.ts +++ b/__tests__/lib/hooks/useGraphData.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { renderHook, waitFor, act } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import React from "react"; +import type { ReactNode } from "react"; import { useGraphData } from "@/lib/hooks/useGraphData"; import { graphApi, type EntityGraphResponse } from "@/lib/api/graph"; @@ -11,6 +14,12 @@ vi.mock("@/lib/api/graph", () => ({ const mockGetEntityGraph = vi.mocked(graphApi.getEntityGraph); +function createWrapper() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return ({ children }: { children: ReactNode }) => + React.createElement(QueryClientProvider, { client: qc }, children); +} + function makeGraphResponse(overrides: Partial = {}): EntityGraphResponse { return { focus_iri: "urn:focus", @@ -64,8 +73,9 @@ describe("useGraphData", () => { }); it("returns null graphData when focusIri is null", () => { - const { result } = renderHook(() => - useGraphData({ focusIri: null, projectId: "proj-1" }), + const { result } = renderHook( + () => useGraphData({ focusIri: null, projectId: "proj-1" }), + { wrapper: createWrapper() }, ); expect(result.current.graphData).toBeNull(); @@ -77,8 +87,9 @@ describe("useGraphData", () => { const response = makeGraphResponse(); mockGetEntityGraph.mockResolvedValue(response); - const { result } = renderHook(() => - useGraphData({ focusIri: "urn:focus", projectId: "proj-1", branch: "main" }), + const { result } = renderHook( + () => useGraphData({ focusIri: "urn:focus", projectId: "proj-1", branch: "main" }), + { wrapper: createWrapper() }, ); await waitFor(() => { @@ -96,8 +107,9 @@ describe("useGraphData", () => { const response = makeGraphResponse(); mockGetEntityGraph.mockResolvedValue(response); - const { result } = renderHook(() => - useGraphData({ focusIri: "urn:focus", projectId: "proj-1" }), + const { result } = renderHook( + () => useGraphData({ focusIri: "urn:focus", projectId: "proj-1" }), + { wrapper: createWrapper() }, ); await waitFor(() => { @@ -120,8 +132,9 @@ describe("useGraphData", () => { it("sets graphData to null on API error", async () => { mockGetEntityGraph.mockRejectedValue(new Error("Network error")); - const { result } = renderHook(() => - useGraphData({ focusIri: "urn:focus", projectId: "proj-1" }), + const { result } = renderHook( + () => useGraphData({ focusIri: "urn:focus", projectId: "proj-1" }), + { wrapper: createWrapper() }, ); await waitFor(() => { @@ -176,8 +189,9 @@ describe("useGraphData", () => { .mockResolvedValueOnce(initial) .mockResolvedValueOnce(expansion); - const { result } = renderHook(() => - useGraphData({ focusIri: "urn:focus", projectId: "proj-1" }), + const { result } = renderHook( + () => useGraphData({ focusIri: "urn:focus", projectId: "proj-1" }), + { wrapper: createWrapper() }, ); await waitFor(() => { @@ -203,8 +217,9 @@ describe("useGraphData", () => { const response = makeGraphResponse(); mockGetEntityGraph.mockResolvedValue(response); - const { result } = renderHook(() => - useGraphData({ focusIri: "urn:focus", projectId: "proj-1" }), + const { result } = renderHook( + () => useGraphData({ focusIri: "urn:focus", projectId: "proj-1" }), + { wrapper: createWrapper() }, ); await waitFor(() => { @@ -253,8 +268,9 @@ describe("useGraphData", () => { .mockResolvedValueOnce(initial) .mockResolvedValueOnce(expansion); - const { result } = renderHook(() => - useGraphData({ focusIri: "urn:focus", projectId: "proj-1" }), + const { result } = renderHook( + () => useGraphData({ focusIri: "urn:focus", projectId: "proj-1" }), + { wrapper: createWrapper() }, ); await waitFor(() => { @@ -285,8 +301,9 @@ describe("useGraphData", () => { .mockResolvedValueOnce(initial) .mockResolvedValueOnce(expansion); - const { result } = renderHook(() => - useGraphData({ focusIri: "urn:focus", projectId: "proj-1" }), + const { result } = renderHook( + () => useGraphData({ focusIri: "urn:focus", projectId: "proj-1" }), + { wrapper: createWrapper() }, ); await waitFor(() => { @@ -310,8 +327,9 @@ describe("useGraphData", () => { it("expandNode does nothing when graphData is null", async () => { mockGetEntityGraph.mockRejectedValue(new Error("fail")); - const { result } = renderHook(() => - useGraphData({ focusIri: "urn:focus", projectId: "proj-1" }), + const { result } = renderHook( + () => useGraphData({ focusIri: "urn:focus", projectId: "proj-1" }), + { wrapper: createWrapper() }, ); await waitFor(() => { @@ -328,8 +346,9 @@ describe("useGraphData", () => { const response = makeGraphResponse(); mockGetEntityGraph.mockResolvedValue(response); - const { result } = renderHook(() => - useGraphData({ focusIri: "urn:focus", projectId: "proj-1" }), + const { result } = renderHook( + () => useGraphData({ focusIri: "urn:focus", projectId: "proj-1" }), + { wrapper: createWrapper() }, ); await waitFor(() => { diff --git a/lib/hooks/useGraphData.ts b/lib/hooks/useGraphData.ts index 66a1dba5..58abd8d9 100644 --- a/lib/hooks/useGraphData.ts +++ b/lib/hooks/useGraphData.ts @@ -1,6 +1,7 @@ "use client"; import { useState, useCallback, useEffect, useMemo, useRef } from "react"; +import { useQuery } from "@tanstack/react-query"; import { graphApi, type EntityGraphResponse } from "@/lib/api/graph"; interface UseGraphDataOptions { @@ -27,47 +28,35 @@ export function useGraphData({ accessToken, }: UseGraphDataOptions): UseGraphDataReturn { const [graphData, setGraphData] = useState(null); - const [isLoading, setIsLoading] = useState(false); const [showDescendants, setShowDescendants] = useState(false); const [resetKey, setResetKey] = useState(0); const expandedNodes = useRef(new Set()); const graphEpoch = useRef(0); const expandingNodes = useRef(new Set()); - // Fetch graph from backend BFS endpoint - useEffect(() => { - if (!focusIri) { - setGraphData(null); - setIsLoading(false); - return; - } - - let cancelled = false; - const epoch = ++graphEpoch.current; - setIsLoading(true); - expandedNodes.current = new Set([focusIri]); - expandingNodes.current = new Set(); - - graphApi - .getEntityGraph(projectId, focusIri, { + const query = useQuery({ + queryKey: ["entityGraph", projectId, focusIri, branch, showDescendants, resetKey, !!accessToken], + queryFn: () => + graphApi.getEntityGraph(projectId, focusIri!, { branch, ancestorsDepth: 5, descendantsDepth: showDescendants ? 2 : 0, - }, accessToken) - .then((data) => { - if (!cancelled) setGraphData(data); - }) - .catch(() => { - if (!cancelled) setGraphData(null); - }) - .finally(() => { - if (!cancelled) setIsLoading(false); - }); + }, accessToken), + enabled: !!focusIri, + staleTime: 30_000, + }); - return () => { - cancelled = true; - }; - }, [focusIri, projectId, branch, showDescendants, resetKey, accessToken]); + // Seed local graphData from query result and reset expansion tracking + useEffect(() => { + if (query.data) { + graphEpoch.current++; + expandedNodes.current = new Set([focusIri!]); + expandingNodes.current = new Set(); + setGraphData(query.data); + } else if (!focusIri) { + setGraphData(null); + } + }, [query.data, focusIri]); // Progressive expansion: fetch 1-hop neighborhood and merge const expandNode = useCallback( @@ -132,7 +121,7 @@ export function useGraphData({ return { graphData, - isLoading, + isLoading: query.isLoading, showDescendants, setShowDescendants, expandNode, From f898c67869b6f150727207c3768ee0833a010c72 Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Sat, 11 Apr 2026 23:25:58 +0200 Subject: [PATCH 22/47] fix: resolve lint errors in useGraphData and test wrapper Fix react/display-name error in test QueryClient wrapper by assigning displayName. Refactor useGraphData to store expansions separately and merge via useMemo, avoiding setState-in-render from ref access during render phase. Co-Authored-By: Claude Opus 4.6 (1M context) --- __tests__/lib/hooks/useGraphData.test.ts | 4 +- lib/hooks/useGraphData.ts | 68 ++++++++++++++---------- 2 files changed, 44 insertions(+), 28 deletions(-) diff --git a/__tests__/lib/hooks/useGraphData.test.ts b/__tests__/lib/hooks/useGraphData.test.ts index 87258cfa..b4e46239 100644 --- a/__tests__/lib/hooks/useGraphData.test.ts +++ b/__tests__/lib/hooks/useGraphData.test.ts @@ -16,8 +16,10 @@ const mockGetEntityGraph = vi.mocked(graphApi.getEntityGraph); function createWrapper() { const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - return ({ children }: { children: ReactNode }) => + const Wrapper = ({ children }: { children: ReactNode }) => React.createElement(QueryClientProvider, { client: qc }, children); + Wrapper.displayName = "QueryWrapper"; + return Wrapper; } function makeGraphResponse(overrides: Partial = {}): EntityGraphResponse { diff --git a/lib/hooks/useGraphData.ts b/lib/hooks/useGraphData.ts index 58abd8d9..1a4d79d8 100644 --- a/lib/hooks/useGraphData.ts +++ b/lib/hooks/useGraphData.ts @@ -21,13 +21,43 @@ interface UseGraphDataReturn { resolvedCount: number; } +function mergeExpansions( + base: EntityGraphResponse, + expansions: EntityGraphResponse[], +): EntityGraphResponse { + if (expansions.length === 0) return base; + + let result = base; + for (const expansion of expansions) { + const existingNodeIds = new Set(result.nodes.map((n) => n.id)); + const existingEdgeIds = new Set(result.edges.map((e) => e.id)); + result = { + ...result, + nodes: [ + ...result.nodes, + ...expansion.nodes.filter((n) => !existingNodeIds.has(n.id)), + ], + edges: [ + ...result.edges, + ...expansion.edges.filter((e) => !existingEdgeIds.has(e.id)), + ], + truncated: result.truncated || expansion.truncated, + total_concept_count: Math.max( + result.total_concept_count, + expansion.total_concept_count, + ), + }; + } + return result; +} + export function useGraphData({ focusIri, projectId, branch, accessToken, }: UseGraphDataOptions): UseGraphDataReturn { - const [graphData, setGraphData] = useState(null); + const [expansions, setExpansions] = useState([]); const [showDescendants, setShowDescendants] = useState(false); const [resetKey, setResetKey] = useState(0); const expandedNodes = useRef(new Set()); @@ -46,18 +76,22 @@ export function useGraphData({ staleTime: 30_000, }); - // Seed local graphData from query result and reset expansion tracking + // Reset expansion tracking when the base query result changes useEffect(() => { if (query.data) { graphEpoch.current++; expandedNodes.current = new Set([focusIri!]); expandingNodes.current = new Set(); - setGraphData(query.data); - } else if (!focusIri) { - setGraphData(null); + setExpansions([]); } }, [query.data, focusIri]); + // Merge base query data with accumulated expansions + const graphData = useMemo(() => { + if (!query.data) return null; + return mergeExpansions(query.data, expansions); + }, [query.data, expansions]); + // Progressive expansion: fetch 1-hop neighborhood and merge const expandNode = useCallback( (iri: string) => { @@ -79,27 +113,7 @@ export function useGraphData({ expandingNodes.current.delete(iri); expandedNodes.current.add(iri); - setGraphData((prev) => { - if (!prev) return newData; - - const existingNodeIds = new Set(prev.nodes.map((n) => n.id)); - const existingEdgeIds = new Set(prev.edges.map((e) => e.id)); - - return { - ...prev, - nodes: [ - ...prev.nodes, - ...newData.nodes.filter((n) => !existingNodeIds.has(n.id)), - ], - edges: [ - ...prev.edges, - ...newData.edges.filter((e) => !existingEdgeIds.has(e.id)), - ], - truncated: prev.truncated || newData.truncated, - total_concept_count: - Math.max(prev.total_concept_count, newData.total_concept_count), - }; - }); + setExpansions((prev) => [...prev, newData]); }) .catch(() => { expandingNodes.current.delete(iri); @@ -110,7 +124,7 @@ export function useGraphData({ const resetGraph = useCallback(() => { expandedNodes.current = new Set(); - setGraphData(null); + setExpansions([]); setResetKey((k) => k + 1); }, []); From a2779d047c0e941cf5c63982aff19c9acc68dde5 Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Sat, 11 Apr 2026 23:59:27 +0200 Subject: [PATCH 23/47] fix: reset expansions on graph context change, not on background refetch The useEffect that resets expansion tracking depended on query.data, causing expansions to be cleared on background refetches (staleTime expiry, window refocus). Now depends on the actual graph input props (projectId, focusIri, branch, showDescendants, resetKey) so expansions are only cleared when the graph context truly changes. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/hooks/useGraphData.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/lib/hooks/useGraphData.ts b/lib/hooks/useGraphData.ts index 1a4d79d8..99dbf8cb 100644 --- a/lib/hooks/useGraphData.ts +++ b/lib/hooks/useGraphData.ts @@ -76,15 +76,13 @@ export function useGraphData({ staleTime: 30_000, }); - // Reset expansion tracking when the base query result changes + // Reset expansion tracking when the graph context changes useEffect(() => { - if (query.data) { - graphEpoch.current++; - expandedNodes.current = new Set([focusIri!]); - expandingNodes.current = new Set(); - setExpansions([]); - } - }, [query.data, focusIri]); + graphEpoch.current++; + expandedNodes.current = focusIri ? new Set([focusIri]) : new Set(); + expandingNodes.current = new Set(); + setExpansions([]); + }, [projectId, focusIri, branch, showDescendants, resetKey]); // Merge base query data with accumulated expansions const graphData = useMemo(() => { From fc5b230be020fb3f0bcf08f26eb5e4e3341ed65f Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Sun, 12 Apr 2026 00:05:14 +0200 Subject: [PATCH 24/47] feat: show one level of descendants by default in graph view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Always fetch descendantsDepth: 1 so direct children appear on initial graph load. Rename showDescendants to showAllDescendants — the toggle now controls whether to fetch deeper descendants (depth 2) rather than toggling descendants on/off entirely. Update button labels and tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/graph/OntologyGraph.test.tsx | 20 +++++++++---------- __tests__/lib/hooks/useGraphData.test.ts | 6 +++--- components/graph/OntologyGraph.tsx | 12 +++++------ lib/hooks/useGraphData.ts | 20 +++++++++---------- 4 files changed, 29 insertions(+), 29 deletions(-) diff --git a/__tests__/components/graph/OntologyGraph.test.tsx b/__tests__/components/graph/OntologyGraph.test.tsx index f8a810df..b340602c 100644 --- a/__tests__/components/graph/OntologyGraph.test.tsx +++ b/__tests__/components/graph/OntologyGraph.test.tsx @@ -100,8 +100,8 @@ const mockGraphData: EntityGraphResponse = { const defaultReturn = { graphData: mockGraphData, isLoading: false, - showDescendants: false, - setShowDescendants: vi.fn(), + showAllDescendants: false, + setShowAllDescendants: vi.fn(), expandNode: vi.fn(), resetGraph: vi.fn(), resolvedCount: 2, @@ -246,7 +246,7 @@ describe("OntologyGraph", () => { it("shows Show Descendants button", () => { render(); - expect(screen.getByLabelText("Show descendants")).toBeDefined(); + expect(screen.getByLabelText("Show all descendants")).toBeDefined(); }); // --- Truncation badge --- @@ -262,17 +262,17 @@ describe("OntologyGraph", () => { // --- Show Descendants toggle --- - it("calls setShowDescendants when descendants button is clicked", () => { + it("calls setShowAllDescendants when descendants button is clicked", () => { render(); - fireEvent.click(screen.getByLabelText("Show descendants")); - expect(defaultReturn.setShowDescendants).toHaveBeenCalledWith(true); + fireEvent.click(screen.getByLabelText("Show all descendants")); + expect(defaultReturn.setShowAllDescendants).toHaveBeenCalledWith(true); }); - it("shows 'Descendants: On' when showDescendants is true", () => { - mockUseGraphData.mockReturnValue({ ...defaultReturn, showDescendants: true }); + it("shows 'All Descendants' when showAllDescendants is true", () => { + mockUseGraphData.mockReturnValue({ ...defaultReturn, showAllDescendants: true }); render(); - expect(screen.getByText("Descendants: On")).toBeDefined(); - expect(screen.getByLabelText("Hide descendants")).toBeDefined(); + expect(screen.getByText("All Descendants")).toBeDefined(); + expect(screen.getByLabelText("Show one level of descendants")).toBeDefined(); }); // --- isLayouting spinner --- diff --git a/__tests__/lib/hooks/useGraphData.test.ts b/__tests__/lib/hooks/useGraphData.test.ts index b4e46239..c8aefa94 100644 --- a/__tests__/lib/hooks/useGraphData.test.ts +++ b/__tests__/lib/hooks/useGraphData.test.ts @@ -101,11 +101,11 @@ describe("useGraphData", () => { expect(mockGetEntityGraph).toHaveBeenCalledWith("proj-1", "urn:focus", { branch: "main", ancestorsDepth: 5, - descendantsDepth: 0, + descendantsDepth: 1, }, undefined); }); - it("passes descendantsDepth=2 when showDescendants is toggled", async () => { + it("passes descendantsDepth=2 when showAllDescendants is toggled", async () => { const response = makeGraphResponse(); mockGetEntityGraph.mockResolvedValue(response); @@ -119,7 +119,7 @@ describe("useGraphData", () => { }); act(() => { - result.current.setShowDescendants(true); + result.current.setShowAllDescendants(true); }); await waitFor(() => { diff --git a/components/graph/OntologyGraph.tsx b/components/graph/OntologyGraph.tsx index ba3d5b3a..daabefc8 100644 --- a/components/graph/OntologyGraph.tsx +++ b/components/graph/OntologyGraph.tsx @@ -139,8 +139,8 @@ export function OntologyGraph({ const { graphData, isLoading, - showDescendants, - setShowDescendants, + showAllDescendants, + setShowAllDescendants, expandNode, resetGraph, resolvedCount, @@ -240,16 +240,16 @@ export function OntologyGraph({ {direction === "TB" ? "Top-Down" : "Left-Right"} - {isLayouting && ( @@ -330,6 +313,21 @@ function OntologyGraphInner({ {/* Graph */}
+ + + + + + + {isLoading && (
@@ -371,6 +369,7 @@ function OntologyGraphInner({ const nodeType = node.data?.nodeType; if (nodeType === "focus") return "#3b82f6"; if (nodeType === "root") return "#ef4444"; + if (nodeType === "secondary_root") return "#64748b"; if (nodeType === "property") return "#93c5fd"; if (nodeType === "individual") return "#f9a8d4"; if (nodeType === "external") return "#e2e8f0"; diff --git a/components/graph/OntologyNode.tsx b/components/graph/OntologyNode.tsx index a9de5f50..090c9346 100644 --- a/components/graph/OntologyNode.tsx +++ b/components/graph/OntologyNode.tsx @@ -29,6 +29,8 @@ const nodeStyles: Record = { "border border-slate-300 bg-white dark:bg-slate-800 dark:border-slate-600", root: "border-[3px] border-red-500 bg-red-50 dark:bg-red-950/30 dark:border-red-500/70 font-semibold", + secondary_root: + "border-2 border-slate-500 bg-slate-100 dark:bg-slate-700 dark:border-slate-400 font-semibold", individual: "border border-pink-300 bg-pink-50 dark:bg-pink-950/30 dark:border-pink-500/60", property: diff --git a/lib/graph/types.ts b/lib/graph/types.ts index 8d4cc12c..0b361da4 100644 --- a/lib/graph/types.ts +++ b/lib/graph/types.ts @@ -1,4 +1,4 @@ -export type GraphNodeType = "focus" | "class" | "root" | "individual" | "property" | "external" | "unexplored"; +export type GraphNodeType = "focus" | "class" | "root" | "secondary_root" | "individual" | "property" | "external" | "unexplored"; export type GraphEdgeType = "subClassOf" | "equivalentClass" | "disjointWith" | "seeAlso"; export interface OntologyGraphNode { diff --git a/lib/graph/useELKLayout.ts b/lib/graph/useELKLayout.ts index 920abacb..c7986835 100644 --- a/lib/graph/useELKLayout.ts +++ b/lib/graph/useELKLayout.ts @@ -20,13 +20,11 @@ export const NODE_WIDTH = 180; export const NODE_HEIGHT = 44; const VALID_NODE_TYPES = new Set([ - "focus", "class", "root", "individual", "property", "external", "unexplored", + "focus", "class", "root", "secondary_root", "individual", "property", "external", "unexplored", ]); function normalizeNodeType(raw: string | undefined): GraphNodeType { if (raw && VALID_NODE_TYPES.has(raw as GraphNodeType)) return raw as GraphNodeType; - // Map backend-specific values to closest frontend type - if (raw === "secondary_root") return "root"; return "class"; } @@ -58,6 +56,10 @@ export function useELKLayout(): LayoutResult { id: n.id, width: Math.max(NODE_WIDTH, n.label.length * 7.5 + 32), height: NODE_HEIGHT, + // Pin root nodes to the first layer so they align at the top + ...(n.node_type === "root" || n.node_type === "secondary_root" + ? { layoutOptions: { "elk.layered.layering.layerConstraint": "FIRST" } } + : {}), })); const elkEdges = data.edges.map((e) => ({ From aa08934509ca8860b0ead7c9f2d6faf2a2d82873 Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Sun, 12 Apr 2026 19:01:20 +0200 Subject: [PATCH 44/47] fix(graph): use normalized edge type for arrow marker condition The marker condition checked the raw backend edge_type, so edges normalized to "subClassOf" via normalizeEdgeType could miss the arrow. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/graph/useELKLayout.ts | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/lib/graph/useELKLayout.ts b/lib/graph/useELKLayout.ts index c7986835..a0a1b5a9 100644 --- a/lib/graph/useELKLayout.ts +++ b/lib/graph/useELKLayout.ts @@ -109,18 +109,21 @@ export function useELKLayout(): LayoutResult { }, ); - const layoutedEdges: Edge[] = data.edges.map((e) => ({ - id: e.id, - source: e.source, - target: e.target, - type: "ontologyEdge", - ...(e.edge_type === "subClassOf" && { - markerEnd: { type: MarkerType.ArrowClosed, color: "#94a3b8", width: 16, height: 16 }, - }), - data: { - edgeType: normalizeEdgeType(e.edge_type), - }, - })); + const layoutedEdges: Edge[] = data.edges.map((e) => { + const normalized = normalizeEdgeType(e.edge_type); + return { + id: e.id, + source: e.source, + target: e.target, + type: "ontologyEdge", + ...(normalized === "subClassOf" && { + markerEnd: { type: MarkerType.ArrowClosed, color: "#94a3b8", width: 16, height: 16 }, + }), + data: { + edgeType: normalized, + }, + }; + }); if (layoutRunRef.current !== localRunId) return; setNodes(layoutedNodes); From ad956390fa6ff67f18c71adba0f2a4ad3ef7edca Mon Sep 17 00:00:00 2001 From: damienriehl Date: Sat, 25 Apr 2026 11:06:26 -0500 Subject: [PATCH 45/47] fix(graph): wire keyboard handlers through useELKLayout (H1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously OntologyNode declared `onNavigate` / `onExpandNode` props plus `role="button" tabIndex={0}` and an `onKeyDown` Enter/Space handler — but useELKLayout never populated the props, so the node advertised itself as a focusable button to screen readers and did nothing on Enter/Space. The inner `onClick` / `onDoubleClick` also competed with ReactFlow's parent event handling. Changes: - useELKLayout exposes `setNodeHandlers({ onNavigate, onExpandNode })`. Handlers live in a ref so swapping them does NOT re-run layout or bust React.memo on OntologyNode. Stable lambdas in node data read from the ref at event time. - OntologyGraph effect calls setNodeHandlers with `expandNode` (single- click semantics) and `onNavigateToClass` (double-click semantics). - OntologyNode drops competing `onClick` / `onDoubleClick` (ReactFlow's parent handlers own mouse events). `onKeyDown` now provides keyboard parity: - Enter / Space → expand (matches single-click) - Shift + Enter → navigate (matches double-click) - aria-label now describes the keyboard shortcuts so screen readers announce the actions. - Tests updated to verify keyboard-only contract. Co-Authored-By: Claude Opus 4.7 --- .../components/graph/OntologyGraph.test.tsx | 1 + .../components/graph/OntologyNode.test.tsx | 78 +++++++------------ components/graph/OntologyGraph.tsx | 17 +++- components/graph/OntologyNode.tsx | 34 ++++---- lib/graph/useELKLayout.ts | 29 ++++++- 5 files changed, 88 insertions(+), 71 deletions(-) diff --git a/__tests__/components/graph/OntologyGraph.test.tsx b/__tests__/components/graph/OntologyGraph.test.tsx index e247f223..7124e060 100644 --- a/__tests__/components/graph/OntologyGraph.test.tsx +++ b/__tests__/components/graph/OntologyGraph.test.tsx @@ -61,6 +61,7 @@ vi.mock("@/lib/graph/useELKLayout", () => ({ edges: [], get isLayouting() { return mockIsLayouting; }, runLayout: mockRunLayout, + setNodeHandlers: vi.fn(), }), })); diff --git a/__tests__/components/graph/OntologyNode.test.tsx b/__tests__/components/graph/OntologyNode.test.tsx index 24451b61..d9cf71cc 100644 --- a/__tests__/components/graph/OntologyNode.test.tsx +++ b/__tests__/components/graph/OntologyNode.test.tsx @@ -49,71 +49,51 @@ describe("OntologyNode", () => { expect(screen.getByTestId("handle-source")).toBeDefined(); }); - it("calls onNavigate on click for non-external nodes", () => { - const onNavigate = vi.fn(); - render( - - ); - fireEvent.click(screen.getByRole("button")); - expect(onNavigate).toHaveBeenCalledWith("node-1"); - }); + // Mouse interactions are handled by ReactFlow at the parent (onNodeClick / + // onNodeDoubleClick), not on the inner div, to avoid double-firing. The + // component therefore exposes ONLY keyboard handlers. - it("does not call onNavigate for external node type", () => { + it("Enter keydown calls onExpandNode (parity with single-click expand)", () => { + const onExpandNode = vi.fn(); const onNavigate = vi.fn(); render( - + ); - fireEvent.click(screen.getByRole("button")); + fireEvent.keyDown(screen.getByRole("button"), { key: "Enter" }); + expect(onExpandNode).toHaveBeenCalledWith("node-1"); expect(onNavigate).not.toHaveBeenCalled(); }); - it("calls onExpandNode on double-click for unexplored nodes", () => { + it("Space keydown calls onExpandNode (parity with single-click expand)", () => { const onExpandNode = vi.fn(); render( - + ); - fireEvent.doubleClick(screen.getByRole("button")); + fireEvent.keyDown(screen.getByRole("button"), { key: " " }); expect(onExpandNode).toHaveBeenCalledWith("node-1"); }); - it("does not call onExpandNode on double-click for non-unexplored nodes", () => { + it("Shift+Enter calls onNavigate (parity with double-click navigate)", () => { + const onNavigate = vi.fn(); const onExpandNode = vi.fn(); render( - + ); - fireEvent.doubleClick(screen.getByRole("button")); + fireEvent.keyDown(screen.getByRole("button"), { key: "Enter", shiftKey: true }); + expect(onNavigate).toHaveBeenCalledWith("node-1"); expect(onExpandNode).not.toHaveBeenCalled(); }); - it("handles Enter keydown for unexplored node", () => { + it("does not invoke handlers for external node type", () => { const onExpandNode = vi.fn(); - render( - - ); - fireEvent.keyDown(screen.getByRole("button"), { key: "Enter" }); - expect(onExpandNode).toHaveBeenCalledWith("node-1"); - }); - - it("handles Enter keydown for regular node (calls onNavigate)", () => { const onNavigate = vi.fn(); render( - + ); fireEvent.keyDown(screen.getByRole("button"), { key: "Enter" }); - expect(onNavigate).toHaveBeenCalledWith("node-1"); - }); - - it("handles Space keydown", () => { - const onNavigate = vi.fn(); - render( - - ); - fireEvent.keyDown(screen.getByRole("button"), { key: " " }); - expect(onNavigate).toHaveBeenCalledWith("node-1"); + fireEvent.keyDown(screen.getByRole("button"), { key: "Enter", shiftKey: true }); + expect(onExpandNode).not.toHaveBeenCalled(); + expect(onNavigate).not.toHaveBeenCalled(); }); it("shows child count when provided and > 0", () => { @@ -162,18 +142,16 @@ describe("OntologyNode", () => { expect(label.className).toContain("line-through"); }); - it("sets aria-label with expand hint for unexplored", () => { - render( - - ); + it("sets aria-label with keyboard hint for non-external nodes", () => { + render(); const btn = screen.getByRole("button"); - expect(btn.getAttribute("aria-label")).toBe( - "TestClass (click to expand)" - ); + expect(btn.getAttribute("aria-label")).toContain("TestClass"); + expect(btn.getAttribute("aria-label")).toContain("Enter to expand"); + expect(btn.getAttribute("aria-label")).toContain("Shift+Enter to navigate"); }); - it("sets aria-label without expand hint for regular nodes", () => { - render(); + it("sets bare aria-label for external nodes", () => { + render(); const btn = screen.getByRole("button"); expect(btn.getAttribute("aria-label")).toBe("TestClass"); }); diff --git a/components/graph/OntologyGraph.tsx b/components/graph/OntologyGraph.tsx index 180dc222..a597be92 100644 --- a/components/graph/OntologyGraph.tsx +++ b/components/graph/OntologyGraph.tsx @@ -159,7 +159,13 @@ function OntologyGraphInner({ const [direction, setDirection] = useState("TB"); const toggleDirection = useCallback(() => setDirection((d) => (d === "TB" ? "LR" : "TB")), []); - const { nodes: layoutNodes, edges: layoutEdges, isLayouting, runLayout } = useELKLayout(); + const { + nodes: layoutNodes, + edges: layoutEdges, + isLayouting, + runLayout, + setNodeHandlers, + } = useELKLayout(); const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const [colorMode, setColorMode] = useState("light"); @@ -191,6 +197,15 @@ function OntologyGraphInner({ }); }, [graphData, direction, runLayout, setNodes, setEdges]); + // Keep node-level keyboard handlers in sync with current callbacks. Stored + // via ref inside useELKLayout, so this does NOT trigger a re-layout. + useEffect(() => { + setNodeHandlers({ + onNavigate: onNavigateToClass, + onExpandNode: expandNode, + }); + }, [setNodeHandlers, onNavigateToClass, expandNode]); + // Sync layout results to React Flow state (skip if graph was cleared) useEffect(() => { if (!graphData || graphData.nodes.length === 0) return; diff --git a/components/graph/OntologyNode.tsx b/components/graph/OntologyNode.tsx index 090c9346..832b691b 100644 --- a/components/graph/OntologyNode.tsx +++ b/components/graph/OntologyNode.tsx @@ -60,28 +60,25 @@ export const OntologyNode = memo(function OntologyNode({ const targetPosition = layoutDirection === "LR" ? Position.Left : Position.Top; const sourcePosition = layoutDirection === "LR" ? Position.Right : Position.Bottom; - const handleClick = () => { + // Mouse interactions are handled by ReactFlow's onNodeClick / onNodeDoubleClick + // at the parent level (OntologyGraph). Keyboard events here provide parity: + // Enter / Space → expand (matches single-click semantics) + // Shift + Enter → navigate (matches double-click semantics) + const handleKeyDown = (e: React.KeyboardEvent) => { if (nodeType === "external") return; - onNavigate?.(id); - }; - - const handleDoubleClick = () => { - if (nodeType === "unexplored") { - onExpandNode?.(id); + if (e.key === "Enter" && e.shiftKey) { + e.preventDefault(); + onNavigate?.(id); + return; } - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); - if (nodeType === "unexplored") { - onExpandNode?.(id); - } else { - handleClick(); - } + onExpandNode?.(id); } }; + const expandable = nodeType !== "external"; + return (
diff --git a/lib/graph/useELKLayout.ts b/lib/graph/useELKLayout.ts index a0a1b5a9..dd5cdbff 100644 --- a/lib/graph/useELKLayout.ts +++ b/lib/graph/useELKLayout.ts @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useRef, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { MarkerType, type Node, type Edge } from "@xyflow/react"; import type { EntityGraphResponse } from "@/lib/api/graph"; import type { OntologyNodeData } from "@/components/graph/OntologyNode"; @@ -9,11 +9,20 @@ import type { GraphNodeType, GraphEdgeType } from "@/lib/graph/types"; export type LayoutDirection = "TB" | "LR"; +export interface NodeHandlers { + onNavigate?: (iri: string) => void; + onExpandNode?: (iri: string) => void; +} + interface LayoutResult { nodes: Node[]; edges: Edge[]; isLayouting: boolean; runLayout: (data: EntityGraphResponse, direction?: LayoutDirection) => Promise; + /** Update keyboard/click handlers without re-running layout. Node data + * receives stable callbacks that read from a ref, so changing handlers + * won't bust React.memo on `OntologyNode`. */ + setNodeHandlers: (handlers: NodeHandlers) => void; } export const NODE_WIDTH = 180; @@ -43,6 +52,20 @@ export function useELKLayout(): LayoutResult { const [isLayouting, setIsLayouting] = useState(false); const layoutRunRef = useRef(0); + // Keyboard/click handlers live in a ref so they can be swapped without + // re-running layout or busting React.memo on OntologyNode. + const handlersRef = useRef({}); + const stableHandlers = useMemo( + () => ({ + onNavigate: (iri: string) => handlersRef.current.onNavigate?.(iri), + onExpandNode: (iri: string) => handlersRef.current.onExpandNode?.(iri), + }), + [], + ); + const setNodeHandlers = useCallback((h: NodeHandlers) => { + handlersRef.current = h; + }, []); + const runLayout = useCallback( async (data: EntityGraphResponse, direction: LayoutDirection = "TB") => { const localRunId = ++layoutRunRef.current; @@ -104,6 +127,8 @@ export function useELKLayout(): LayoutResult { deprecated: false, isExpanded: false, layoutDirection: direction, + onNavigate: stableHandlers.onNavigate, + onExpandNode: stableHandlers.onExpandNode, }, }; }, @@ -135,5 +160,5 @@ export function useELKLayout(): LayoutResult { [], ); - return { nodes, edges, isLayouting, runLayout }; + return { nodes, edges, isLayouting, runLayout, setNodeHandlers }; } From df83589479ffc00b2a842d8147be0e289419cf10 Mon Sep 17 00:00:00 2001 From: damienriehl Date: Sat, 25 Apr 2026 11:06:32 -0500 Subject: [PATCH 46/47] fix(graph): honor graphEdgeStyle store setting in OntologyEdge (H2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `graphEdgeStyle` Zustand setting (bezier vs smoothstep) was added, persisted to localStorage, defaulted to 'smoothstep', AND exposed via a toolbar — but OntologyEdge always called getBezierPath, so the setting silently lied to the user. OntologyEdge now subscribes to the store and dispatches between getBezierPath / getSmoothStepPath based on the user's choice. OntologyEdge.test.tsx mocks the store (default: 'smoothstep') and the new getSmoothStepPath import to keep the test fast and matchMedia-free. Co-Authored-By: Claude Opus 4.7 --- __tests__/components/graph/OntologyEdge.test.tsx | 15 ++++++++++++--- components/graph/OntologyEdge.tsx | 6 +++++- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/__tests__/components/graph/OntologyEdge.test.tsx b/__tests__/components/graph/OntologyEdge.test.tsx index b1c94f47..83ff6c93 100644 --- a/__tests__/components/graph/OntologyEdge.test.tsx +++ b/__tests__/components/graph/OntologyEdge.test.tsx @@ -17,9 +17,18 @@ vi.mock("@xyflow/react", () => ({
{children}
), getBezierPath: (): [string, number, number] => ["M0,0 C10,10 20,20 30,30", 15, 15], + getSmoothStepPath: (): [string, number, number] => ["M0,0 L10,10 L30,30", 15, 15], Position: { Top: "top", Bottom: "bottom", Left: "left", Right: "right" }, })); +// Mock the editor mode store — OntologyEdge subscribes for graphEdgeStyle. +// Without this, Zustand's persist middleware initializes against window.matchMedia +// (system theme detection), which jsdom doesn't ship. +vi.mock("@/lib/stores/editorModeStore", () => ({ + useEditorModeStore: (selector: (s: { graphEdgeStyle: string }) => unknown) => + selector({ graphEdgeStyle: "smoothstep" }), +})); + describe("OntologyEdge", () => { const baseProps = { id: "edge-1", @@ -190,14 +199,14 @@ describe("OntologyEdge", () => { expect(hoverPath?.getAttribute("stroke-width")).toBe("16"); }); - it("always renders with bezier path", () => { + it("renders smoothstep path when graphEdgeStyle is 'smoothstep' (per store mock)", () => { render( ); const edge = screen.getByTestId("base-edge-edge-1"); - // getBezierPath mock returns a cubic bezier path - expect(edge.getAttribute("d")).toBe("M0,0 C10,10 20,20 30,30"); + // Default mock returns "smoothstep" — getSmoothStepPath is invoked. + expect(edge.getAttribute("d")).toBe("M0,0 L10,10 L30,30"); }); }); diff --git a/components/graph/OntologyEdge.tsx b/components/graph/OntologyEdge.tsx index 8d33cb62..256fdad1 100644 --- a/components/graph/OntologyEdge.tsx +++ b/components/graph/OntologyEdge.tsx @@ -5,9 +5,11 @@ import { BaseEdge, EdgeLabelRenderer, getBezierPath, + getSmoothStepPath, type EdgeProps, } from "@xyflow/react"; import type { GraphEdgeType } from "@/lib/graph/types"; +import { useEditorModeStore } from "@/lib/stores/editorModeStore"; export interface OntologyEdgeData { [key: string]: unknown; @@ -59,8 +61,10 @@ export const OntologyEdge = memo(function OntologyEdge({ const [hovered, setHovered] = useState(false); const edgeType = data?.edgeType ?? "subClassOf"; const config = edgeTypeConfig[edgeType]; + const edgeStyle = useEditorModeStore((s) => s.graphEdgeStyle); - const [edgePath, labelX, labelY] = getBezierPath({ + const pathBuilder = edgeStyle === "smoothstep" ? getSmoothStepPath : getBezierPath; + const [edgePath, labelX, labelY] = pathBuilder({ sourceX, sourceY, sourcePosition, From 724ea455a821f6fa874998135f2e360f97633407 Mon Sep 17 00:00:00 2001 From: damienriehl Date: Sat, 25 Apr 2026 11:06:40 -0500 Subject: [PATCH 47/47] fix(graph): drop redundant OntologyGraphNode/Edge/GraphData types (H3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These types were leftover from the deleted client-side graph builder (`lib/graph/buildGraphData.ts`). After the server-side BFS port, the canonical graph payload is `EntityGraphResponse` from `lib/api/graph.ts`, and grep confirms no consumers import OntologyGraphNode, OntologyGraphEdge, or GraphData. Keeping them invited future contributors to use the wrong vocabulary. The remaining `GraphNodeType` / `GraphEdgeType` literal unions stay — they are the rendering-side mirrors of the API enum, also referenced in Python (ontokit-api ontokit/schemas/graph.py). Co-Authored-By: Claude Opus 4.7 --- lib/graph/types.ts | 37 +++++++++++++++---------------------- 1 file changed, 15 insertions(+), 22 deletions(-) diff --git a/lib/graph/types.ts b/lib/graph/types.ts index 0b361da4..3b3068d8 100644 --- a/lib/graph/types.ts +++ b/lib/graph/types.ts @@ -1,23 +1,16 @@ -export type GraphNodeType = "focus" | "class" | "root" | "secondary_root" | "individual" | "property" | "external" | "unexplored"; -export type GraphEdgeType = "subClassOf" | "equivalentClass" | "disjointWith" | "seeAlso"; - -export interface OntologyGraphNode { - id: string; - label: string; - nodeType: GraphNodeType; - deprecated?: boolean; - childCount?: number; - isExpanded?: boolean; -} +// String-literal union types shared between rendering layer (OntologyNode, +// OntologyEdge, useELKLayout) and the API client (`lib/api/graph.ts`). +// +// Mirror of the Python `GraphNodeType` / `GraphEdgeType` literals in +// `ontokit/schemas/graph.py` — keep in sync when adding new values. +export type GraphNodeType = + | "focus" + | "class" + | "root" + | "secondary_root" + | "individual" + | "property" + | "external" + | "unexplored"; -export interface OntologyGraphEdge { - id: string; - source: string; - target: string; - edgeType: GraphEdgeType; -} - -export interface GraphData { - nodes: OntologyGraphNode[]; - edges: OntologyGraphEdge[]; -} +export type GraphEdgeType = "subClassOf" | "equivalentClass" | "disjointWith" | "seeAlso";