Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions src/components/layout/connections-section.tsx
Original file line number Diff line number Diff line change
@@ -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<GroupBy>("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<string, typeof connections>()
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 (
<div className="space-y-3">
{/* Header */}
<div className="flex items-center justify-between gap-2">
<p className="text-[10px] font-mono text-muted-foreground uppercase tracking-wider">
Connections
</p>
<div className="flex items-center gap-0.5 rounded-md border border-border/30 p-0.5">
<button
onClick={() => setGroupBy("edge_type")}
className={`rounded px-2 py-0.5 text-[9px] font-mono transition-colors ${
groupBy === "edge_type"
? "bg-border/50 text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
Edge Type
</button>
<button
onClick={() => setGroupBy("node_type")}
className={`rounded px-2 py-0.5 text-[9px] font-mono transition-colors ${
groupBy === "node_type"
? "bg-border/50 text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
Node Type
</button>
</div>
</div>

{/* Body */}
{connections.length === 0 ? (
<p className="text-xs text-muted-foreground">No connections</p>
) : (
<div className="space-y-3">
{groups.map(([groupKey, conns]) => (
<div key={groupKey} className="space-y-1">
<p className="text-[9px] font-mono text-muted-foreground uppercase tracking-wider">
{groupBy === "node_type" ? displayNodeType(groupKey) : groupKey}{" "}
<span className="text-muted-foreground/60">({conns.length})</span>
</p>
{conns.map((conn, i) => (
<div
key={`${conn.peer.ref_id}-${i}`}
className="flex items-center justify-between gap-2 rounded-md px-2 py-1.5 bg-muted/20 border border-border/20"
>
<span className="text-xs truncate min-w-0">{resolveTitle(conn.peer)}</span>
<Badge
variant="outline"
className="text-[9px] px-1.5 py-0 h-4 border-border/50 text-muted-foreground font-mono shrink-0"
>
{displayNodeType(conn.peer.node_type)}
</Badge>
</div>
))}
</div>
))}
</div>
)}
</div>
)
}
5 changes: 5 additions & 0 deletions src/components/layout/node-preview-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -567,6 +568,10 @@ export function NodePreviewPanel({ node, onBack, schemas }: NodePreviewPanelProp
)}
</div>
)}
{/* Connections — always visible regardless of unlock state */}
<div className="pt-2 border-t border-border/30">
<ConnectionsSection nodeRefId={node.ref_id} schemas={schemas} />
</div>
</div>
</ScrollArea>
</div>
Expand Down
137 changes: 137 additions & 0 deletions src/lib/__tests__/connections-section.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<ConnectionsSection nodeRefId="n1" schemas={[]} />)
expect(screen.getByText("Connections")).toBeInTheDocument()
})

it("groups by edge type with correct counts", () => {
render(<ConnectionsSection nodeRefId="n1" schemas={[]} />)
// 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(<ConnectionsSection nodeRefId="n1" schemas={[]} />)
expect(screen.getByText("Blockchain")).toBeInTheDocument()
expect(screen.getByText("AI")).toBeInTheDocument()
expect(screen.getByText("Alice")).toBeInTheDocument()
})

it("shows node type badges for each row", () => {
render(<ConnectionsSection nodeRefId="n1" schemas={[]} />)
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(<ConnectionsSection nodeRefId="n1" schemas={[]} />)
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(<ConnectionsSection nodeRefId="n1" schemas={[]} />)
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(<ConnectionsSection nodeRefId="n1" schemas={[]} />)
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(<ConnectionsSection nodeRefId="n1" schemas={[]} />)
expect(screen.getByText("No connections")).toBeInTheDocument()
})

it("does not render any group headers when empty", () => {
render(<ConnectionsSection nodeRefId="n1" schemas={[]} />)
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(<ConnectionsSection nodeRefId="n1" schemas={[]} />)
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(<ConnectionsSection nodeRefId="n1" schemas={[]} />)
const row = screen.getByText("Blockchain").closest("div")
expect(row).not.toHaveAttribute("onClick")
})
})
31 changes: 28 additions & 3 deletions src/lib/__tests__/node-preview-panel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand Down Expand Up @@ -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(<NodePreviewPanel node={BASE_NODE} onBack={vi.fn()} schemas={[]} />)
await waitFor(() => {
expect(screen.getByText("Connections")).toBeInTheDocument()
})
})

it("shows 'No connections' empty state when store has no edges for this node", async () => {
render(<NodePreviewPanel node={BASE_NODE} onBack={vi.fn()} schemas={[]} />)
await waitFor(() => {
expect(screen.getByText("No connections")).toBeInTheDocument()
})
})
})

describe("NodePreviewPanel – price display", () => {
beforeEach(() => {
vi.clearAllMocks()
Expand Down
Loading