From c1ca0e72afde2a52e2047290306ba2aec279a75a Mon Sep 17 00:00:00 2001 From: tomsmith8 Date: Wed, 29 Apr 2026 11:29:31 +0000 Subject: [PATCH] Generated with Hive: Add Connections section to NodePreviewPanel with grouping toggle and tests --- src/components/layout/connections-section.tsx | 122 ++++++++++++++++ src/components/layout/node-preview-panel.tsx | 5 + .../__tests__/connections-section.test.tsx | 137 ++++++++++++++++++ src/lib/__tests__/node-preview-panel.test.tsx | 31 +++- 4 files changed, 292 insertions(+), 3 deletions(-) create mode 100644 src/components/layout/connections-section.tsx create mode 100644 src/lib/__tests__/connections-section.test.tsx diff --git a/src/components/layout/connections-section.tsx b/src/components/layout/connections-section.tsx new file mode 100644 index 0000000..1648a2f --- /dev/null +++ b/src/components/layout/connections-section.tsx @@ -0,0 +1,122 @@ +"use client" + +import { useMemo, useState } from "react" +import { Badge } from "@/components/ui/badge" +import { useGraphStore } from "@/stores/graph-store" +import { pickString, DISPLAY_KEY_FALLBACKS } from "@/lib/node-display" +import { displayNodeType } from "@/lib/utils" +import type { SchemaNode } from "@/app/ontology/page" + +interface ConnectionsSectionProps { + nodeRefId: string + schemas: SchemaNode[] +} + +type GroupBy = "edge_type" | "node_type" + +export function ConnectionsSection({ nodeRefId, schemas }: ConnectionsSectionProps) { + const [groupBy, setGroupBy] = useState("edge_type") + const nodes = useGraphStore((s) => s.nodes) + const edges = useGraphStore((s) => s.edges) + + const connections = useMemo(() => { + const nodeMap = new Map(nodes.map((n) => [n.ref_id, n])) + return edges + .filter((e) => e.source === nodeRefId || e.target === nodeRefId) + .flatMap((e) => { + const peerId = e.source === nodeRefId ? e.target : e.source + const peer = nodeMap.get(peerId) + if (!peer) return [] + return [{ edge_type: e.edge_type, peer }] + }) + }, [edges, nodes, nodeRefId]) + + // Group connections + const groups = useMemo(() => { + const map = new Map() + for (const conn of connections) { + const key = groupBy === "edge_type" ? conn.edge_type : conn.peer.node_type + const existing = map.get(key) ?? [] + map.set(key, [...existing, conn]) + } + return Array.from(map.entries()).sort((a, b) => a[0].localeCompare(b[0])) + }, [connections, groupBy]) + + function resolveTitle(peer: (typeof connections)[number]["peer"]): string { + const props = peer.properties + const schema = schemas.find((s) => s.type === peer.node_type) + let title = + pickString(props, schema?.title_key) ?? + pickString(props, schema?.index) + if (!title) { + for (const key of DISPLAY_KEY_FALLBACKS) { + title = pickString(props, key) + if (title) break + } + } + return title ?? peer.ref_id + } + + return ( +
+ {/* Header */} +
+

+ Connections +

+
+ + +
+
+ + {/* Body */} + {connections.length === 0 ? ( +

No connections

+ ) : ( +
+ {groups.map(([groupKey, conns]) => ( +
+

+ {groupBy === "node_type" ? displayNodeType(groupKey) : groupKey}{" "} + ({conns.length}) +

+ {conns.map((conn, i) => ( +
+ {resolveTitle(conn.peer)} + + {displayNodeType(conn.peer.node_type)} + +
+ ))} +
+ ))} +
+ )} +
+ ) +} diff --git a/src/components/layout/node-preview-panel.tsx b/src/components/layout/node-preview-panel.tsx index ee593bb..a6adafb 100644 --- a/src/components/layout/node-preview-panel.tsx +++ b/src/components/layout/node-preview-panel.tsx @@ -19,6 +19,7 @@ import { pickString, DISPLAY_KEY_FALLBACKS } from "@/lib/node-display" import { getStatusBadge } from "@/lib/node-status" import type { GraphNode, GraphData } from "@/lib/graph-api" import type { SchemaNode } from "@/app/ontology/page" +import { ConnectionsSection } from "./connections-section" const INTERNAL_FIELDS = new Set([ "ref_id", "pubkey", "node_type", "date_added_to_graph", "status", @@ -567,6 +568,10 @@ export function NodePreviewPanel({ node, onBack, schemas }: NodePreviewPanelProp )} )} + {/* Connections — always visible regardless of unlock state */} +
+ +
diff --git a/src/lib/__tests__/connections-section.test.tsx b/src/lib/__tests__/connections-section.test.tsx new file mode 100644 index 0000000..808e9b9 --- /dev/null +++ b/src/lib/__tests__/connections-section.test.tsx @@ -0,0 +1,137 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { render, screen, fireEvent } from "@testing-library/react" +import React from "react" +import type { GraphNode, GraphEdge } from "@/lib/graph-api" + +// --- mock useGraphStore --- +let mockNodes: GraphNode[] = [] +let mockEdges: GraphEdge[] = [] + +vi.mock("@/stores/graph-store", () => ({ + useGraphStore: (sel: (s: { nodes: GraphNode[]; edges: GraphEdge[] }) => unknown) => + sel({ nodes: mockNodes, edges: mockEdges }), +})) + +import { ConnectionsSection } from "@/components/layout/connections-section" + +const N1: GraphNode = { ref_id: "n1", node_type: "Episode", properties: { name: "My Episode" } } +const N2: GraphNode = { ref_id: "n2", node_type: "Topic", properties: { name: "Blockchain" } } +const N3: GraphNode = { ref_id: "n3", node_type: "Topic", properties: { title: "AI" } } +const N4: GraphNode = { ref_id: "n4", node_type: "Person", properties: { name: "Alice" } } + +const EDGE_MENTIONS_N2: GraphEdge = { source: "n1", target: "n2", edge_type: "MENTIONS" } +const EDGE_MENTIONS_N3: GraphEdge = { source: "n1", target: "n3", edge_type: "MENTIONS" } +const EDGE_CREATED_N4: GraphEdge = { source: "n4", target: "n1", edge_type: "CREATED" } +const EDGE_ABOUT_N4: GraphEdge = { source: "n1", target: "n4", edge_type: "ABOUT" } + +describe("ConnectionsSection – edge type grouping (default)", () => { + beforeEach(() => { + vi.clearAllMocks() + mockNodes = [N1, N2, N3, N4] + mockEdges = [EDGE_MENTIONS_N2, EDGE_MENTIONS_N3, EDGE_CREATED_N4] + }) + + it("renders the Connections heading", () => { + render() + expect(screen.getByText("Connections")).toBeInTheDocument() + }) + + it("groups by edge type with correct counts", () => { + render() + // MENTIONS group has 2 peers + expect(screen.getByText(/MENTIONS/)).toBeInTheDocument() + expect(screen.getByText("(2)")).toBeInTheDocument() + // CREATED group has 1 peer (n4 -> n1, peer is n4) + expect(screen.getByText(/CREATED/)).toBeInTheDocument() + expect(screen.getByText("(1)")).toBeInTheDocument() + }) + + it("shows correct target titles in edge type mode", () => { + render() + expect(screen.getByText("Blockchain")).toBeInTheDocument() + expect(screen.getByText("AI")).toBeInTheDocument() + expect(screen.getByText("Alice")).toBeInTheDocument() + }) + + it("shows node type badges for each row", () => { + render() + const topicBadges = screen.getAllByText("Topic") + expect(topicBadges.length).toBeGreaterThanOrEqual(2) + expect(screen.getByText("Person")).toBeInTheDocument() + }) +}) + +describe("ConnectionsSection – node type grouping toggle", () => { + beforeEach(() => { + mockNodes = [N1, N2, N3, N4] + mockEdges = [EDGE_MENTIONS_N2, EDGE_MENTIONS_N3, EDGE_CREATED_N4, EDGE_ABOUT_N4] + }) + + it("switches to node type grouping when Node Type button is clicked", () => { + render() + fireEvent.click(screen.getByRole("button", { name: "Node Type" })) + // Topic group header should appear (may also appear in badges — use getAllByText) + expect(screen.getAllByText("Topic").length).toBeGreaterThan(0) + // Person group header should appear + expect(screen.getAllByText("Person").length).toBeGreaterThan(0) + }) + + it("shows group count in node type mode", () => { + render() + fireEvent.click(screen.getByRole("button", { name: "Node Type" })) + // Both Topic (2) and Person (2) render "(2)" — verify at least one exists + expect(screen.getAllByText("(2)").length).toBeGreaterThanOrEqual(1) + }) + + it("can toggle back to edge type mode", () => { + render() + fireEvent.click(screen.getByRole("button", { name: "Node Type" })) + fireEvent.click(screen.getByRole("button", { name: "Edge Type" })) + expect(screen.getByText(/MENTIONS/)).toBeInTheDocument() + }) +}) + +describe("ConnectionsSection – empty state", () => { + beforeEach(() => { + mockNodes = [N1] + mockEdges = [] + }) + + it("renders 'No connections' when there are no edges", () => { + render() + expect(screen.getByText("No connections")).toBeInTheDocument() + }) + + it("does not render any group headers when empty", () => { + render() + expect(screen.queryByText(/MENTIONS/)).toBeNull() + }) +}) + +describe("ConnectionsSection – skips peer nodes not in store", () => { + beforeEach(() => { + // n3 is referenced in an edge but NOT in the nodes array + mockNodes = [N1, N2] + mockEdges = [EDGE_MENTIONS_N2, EDGE_MENTIONS_N3] + }) + + it("only renders peers whose nodes exist in the store", () => { + render() + expect(screen.getByText("Blockchain")).toBeInTheDocument() + // AI (n3) should not appear since n3 not in store + expect(screen.queryByText("AI")).toBeNull() + }) +}) + +describe("ConnectionsSection – rows are read-only", () => { + beforeEach(() => { + mockNodes = [N1, N2] + mockEdges = [EDGE_MENTIONS_N2] + }) + + it("connection rows have no onClick handler", () => { + render() + const row = screen.getByText("Blockchain").closest("div") + expect(row).not.toHaveAttribute("onClick") + }) +}) diff --git a/src/lib/__tests__/node-preview-panel.test.tsx b/src/lib/__tests__/node-preview-panel.test.tsx index 2b57eb5..4390308 100644 --- a/src/lib/__tests__/node-preview-panel.test.tsx +++ b/src/lib/__tests__/node-preview-panel.test.tsx @@ -57,9 +57,11 @@ vi.mock("@/stores/modal-store", () => ({ })) vi.mock("@/stores/graph-store", () => ({ - useGraphStore: { - getState: () => ({ addNodes: vi.fn() }), - }, + useGraphStore: Object.assign( + (sel: (s: { nodes: never[]; edges: never[]; addNodes: () => void }) => unknown) => + sel({ nodes: [], edges: [], addNodes: vi.fn() }), + { getState: () => ({ addNodes: vi.fn() }) }, + ), })) vi.mock("@/stores/player-store", () => ({ @@ -96,6 +98,29 @@ function makeGraphData(node: GraphNode) { return { nodes: [node], edges: [] } } +describe("NodePreviewPanel – Connections section smoke test", () => { + beforeEach(() => { + vi.clearAllMocks() + userStoreOverrides = {} + // Resolve the probe immediately so unlocked state is reached + mockApiGet.mockResolvedValue(makeGraphData(BASE_NODE)) + }) + + it("renders the Connections heading for any node", async () => { + render() + await waitFor(() => { + expect(screen.getByText("Connections")).toBeInTheDocument() + }) + }) + + it("shows 'No connections' empty state when store has no edges for this node", async () => { + render() + await waitFor(() => { + expect(screen.getByText("No connections")).toBeInTheDocument() + }) + }) +}) + describe("NodePreviewPanel – price display", () => { beforeEach(() => { vi.clearAllMocks()