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
9 changes: 9 additions & 0 deletions src/app/ontology/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,18 @@ export interface SchemaNode {

export interface SchemaEdge {
ref_id: string
// `source`/`target` are the connected schema NODES' ref_ids (used to lay out
// the ontology graph), NOT type names. Use `source_type`/`target_type` to
// match against a node's node_type.
source: string
target: string
edge_type: string
source_type?: string
target_type?: string
// Attribute definitions for this edge type, e.g. { since: "?datetime",
// role: "string" }. A leading "?" marks the attribute optional. Present on
// the live /schema/all payload; absent on some mock fixtures.
attributes?: Record<string, string>
}

export default function OntologyPage() {
Expand Down
300 changes: 286 additions & 14 deletions src/components/modals/add-edge-form.tsx

Large diffs are not rendered by default.

12 changes: 6 additions & 6 deletions src/components/modals/budget-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ function WithdrawStep({
}

export function BudgetModal() {
const { activeModal, close } = useModalStore()
const { budgetOpen, closeBudget } = useModalStore()
const { budget, setBudget } = useUserStore()
const refreshBalance = useUserStore((s) => s.refreshBalance)
const [loading, setLoading] = useState(false)
Expand Down Expand Up @@ -174,8 +174,8 @@ export function BudgetModal() {
}, [])

useEffect(() => {
if (activeModal !== "budget") resetState()
}, [activeModal, resetState])
if (!budgetOpen) resetState()
}, [budgetOpen, resetState])

useEffect(() => {
return () => {
Expand All @@ -187,7 +187,7 @@ export function BudgetModal() {
// resuming it or generating a new one — instead of silently auto-resuming.
const resumeAttemptedRef = useRef(false)
useEffect(() => {
if (activeModal !== "budget") {
if (!budgetOpen) {
resumeAttemptedRef.current = false
return
}
Expand All @@ -210,7 +210,7 @@ export function BudgetModal() {
setPendingChallenge(pending)
setFirstPurchaseAmount(pending.amount)
setStep("first-purchase")
}, [activeModal])
}, [budgetOpen])

// Resume polling for the stored pending invoice. Does a single quick status
// check first — if the invoice was already paid while the user was away,
Expand Down Expand Up @@ -553,7 +553,7 @@ export function BudgetModal() {
const successDelta = amount ?? (reachedViaFirstPurchase ? firstPurchaseAmount : null)

return (
<Dialog open={activeModal === "budget"} onOpenChange={() => close()}>
<Dialog open={budgetOpen} onOpenChange={() => closeBudget()}>
<DialogContent className="border-border/50 bg-card noise-bg sm:max-w-sm">
<DialogHeader>
<DialogTitle className="font-heading text-lg tracking-wide flex items-center gap-2">
Expand Down
4 changes: 2 additions & 2 deletions src/components/ui/node-search-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { X, Loader2 } from "lucide-react"
import { cn } from "@/lib/utils"
import { displayNodeType } from "@/lib/utils"
import { resolveNodeTitle } from "@/lib/node-display"
import { searchNodes, type GraphNode } from "@/lib/graph-api"
import { searchNodesForEdge, type GraphNode } from "@/lib/graph-api"
import { useDebounce } from "@/hooks/use-debounce"
import { useSchemaStore } from "@/stores/schema-store"
import { AnchoredPopover } from "@/components/ui/anchored-popover"
Expand Down Expand Up @@ -63,7 +63,7 @@ export function NodeSearchInput({
setLoading(true)
setFetched(false)

searchNodes(debouncedQuery, { limit: 10 }, controller.signal)
searchNodesForEdge(debouncedQuery, { limit: 10 }, controller.signal)
.then((res) => {
if (!controller.signal.aborted) {
setResults(res.nodes)
Expand Down
97 changes: 91 additions & 6 deletions src/lib/__tests__/add-edge-modal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const { mockCreateEdge, mockSearchNodes } = vi.hoisted(() => ({

vi.mock("@/lib/graph-api", () => ({
createEdge: (...args: unknown[]) => mockCreateEdge(...args),
searchNodes: (...args: unknown[]) => mockSearchNodes(...args),
searchNodesForEdge: (...args: unknown[]) => mockSearchNodes(...args),
}))

vi.mock("@/lib/mock-data", () => ({
Expand Down Expand Up @@ -70,12 +70,21 @@ vi.mock("@/stores/modal-store", () => ({
// ---------------------------------------------------------------------------
// Schema store — per-selector mock with sample edges
// ---------------------------------------------------------------------------
// Edge schemas are directional source_type -> target_type. `source`/`target`
// hold the connected schema NODES' ref_ids (not type names); type matching uses
// source_type/target_type. FIXTURE_SOURCE is a Topic and FIXTURE_TARGET a
// Person, so the Topic->Person edges are what the picker offers once both are
// selected. `attributes` carry the edge type's property definitions.
const SCHEMA_EDGES = [
{ ref_id: "e1", edge_type: "HAS_TOPIC", from_type: "Person", to_type: "Topic" },
{ ref_id: "e2", edge_type: "AUTHORED_BY", from_type: "Content", to_type: "Person" },
{ ref_id: "e3", edge_type: "HAS_TOPIC", from_type: "Content", to_type: "Topic" }, // duplicate — should dedupe
{ ref_id: "e4", edge_type: "CHILD_OF", from_type: "Episode", to_type: "Episode" }, // excluded
{ ref_id: "e5", edge_type: "RELATED_TO", from_type: "Person", to_type: "Person" },
{ ref_id: "e1", edge_type: "HAS_TOPIC", source: "s-topic", target: "s-person", source_type: "Topic", target_type: "Person" },
{ ref_id: "e2", edge_type: "AUTHORED_BY", source: "s-content", target: "s-person", source_type: "Content", target_type: "Person" },
{ ref_id: "e3", edge_type: "HAS_TOPIC", source: "s-content", target: "s-topic", source_type: "Content", target_type: "Topic" }, // duplicate edge_type — should dedupe
{ ref_id: "e4", edge_type: "CHILD_OF", source: "s-ep", target: "s-ep", source_type: "Episode", target_type: "Episode" }, // excluded
{ ref_id: "e5", edge_type: "RELATED_TO", source: "s-person", target: "s-person2", source_type: "Person", target_type: "Person" },
// Topic->Person with optional attribute definitions (schema-driven fields)
{ ref_id: "e6", edge_type: "MENTIONS", source: "s-topic", target: "s-person", source_type: "Topic", target_type: "Person", attributes: { note: "?string", confidence: "?float" } },
// Topic->Person with a REQUIRED attribute
{ ref_id: "e7", edge_type: "ROLE_AT", source: "s-topic", target: "s-person", source_type: "Topic", target_type: "Person", attributes: { role: "string" } },
]

vi.mock("@/stores/schema-store", () => ({
Expand Down Expand Up @@ -168,6 +177,75 @@ describe("AddEdgeForm", () => {
expect(screen.getAllByText("AUTHORED_BY").length).toBeGreaterThan(0)
expect(screen.getAllByText("RELATED_TO").length).toBeGreaterThan(0)
})

it("renders schema-defined properties and sends filled ones as edge_data", async () => {
withSource(null)
render(<AddEdgeForm />)
await selectNode("Search source node…", FIXTURE_SOURCE) // Topic
await selectNode("Search target node…", FIXTURE_TARGET) // Person
const trigger = screen.getByText("Choose an edge type...").closest("button") as HTMLButtonElement
await userEvent.click(trigger)
await userEvent.click(screen.getByText("MENTIONS"))
// Optional field "note" (?string) renders with a type-hint placeholder
const noteInput = screen.getByPlaceholderText("string")
await userEvent.type(noteInput, "as discussed")
await userEvent.click(screen.getByRole("button", { name: /add edge/i }))
await waitFor(() => {
expect(mockCreateEdge).toHaveBeenCalledWith({
source: "node-source-ref",
target: "node-target-ref",
edge_type: "MENTIONS",
edge_data: { note: "as discussed" },
})
})
})

it("blocks submit when a required schema property is empty", async () => {
withSource(null)
render(<AddEdgeForm />)
await selectNode("Search source node…", FIXTURE_SOURCE)
await selectNode("Search target node…", FIXTURE_TARGET)
const trigger = screen.getByText("Choose an edge type...").closest("button") as HTMLButtonElement
await userEvent.click(trigger)
await userEvent.click(screen.getByText("ROLE_AT"))
await userEvent.click(screen.getByRole("button", { name: /add edge/i }))
expect(screen.getByText(/Missing required property: role/i)).toBeDefined()
expect(mockCreateEdge).not.toHaveBeenCalled()
})

it("custom type mode sends a free-typed edge_type with create_schema_if_missing", async () => {
withSource(null)
render(<AddEdgeForm />)
await selectNode("Search source node…", FIXTURE_SOURCE)
await selectNode("Search target node…", FIXTURE_TARGET)
// Switch to free-text mode and type a brand-new relationship type
await userEvent.click(screen.getByText("+ Custom type"))
const input = screen.getByPlaceholderText("e.g. FOUND_AT")
await userEvent.type(input, "found at")
await userEvent.click(screen.getByRole("button", { name: /add edge/i }))
await waitFor(() => {
expect(mockCreateEdge).toHaveBeenCalledWith({
source: "node-source-ref",
target: "node-target-ref",
edge_type: "FOUND_AT",
create_schema_if_missing: true,
})
})
})

it("filters edge types to those valid for the selected source/target node types", async () => {
withSource(null)
render(<AddEdgeForm />)
// Topic (source) -> Person (target): only HAS_TOPIC is defined for this pair
await selectNode("Search source node…", FIXTURE_SOURCE)
await selectNode("Search target node…", FIXTURE_TARGET)
const trigger = screen.getByText("Choose an edge type...").closest("button") as HTMLButtonElement
await userEvent.click(trigger)
expect(screen.getAllByText("HAS_TOPIC").length).toBeGreaterThan(0)
// Defined for other type pairs — must not be offered here
expect(screen.queryByText("AUTHORED_BY")).toBeNull()
expect(screen.queryByText("RELATED_TO")).toBeNull()
})
})

// -------------------------------------------------------------------------
Expand Down Expand Up @@ -310,6 +388,13 @@ describe("AddEdgeForm", () => {

await waitFor(() => expect(mockOpen).toHaveBeenCalledWith("budget"))
expect(mockClose).not.toHaveBeenCalled()
// Form must be re-enabled (not wedged on "Creating…") so the user can
// retry after topping up.
await waitFor(() => {
const btn = screen.getByRole("button", { name: /add edge/i })
expect(btn).not.toBeDisabled()
})
expect(screen.queryByRole("button", { name: /creating/i })).toBeNull()
})

it("shows inline error and keeps the form mounted when createEdge rejects", async () => {
Expand Down
9 changes: 8 additions & 1 deletion src/lib/__tests__/budget-modal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,14 @@ const mockClose = vi.fn()

vi.mock("@/stores/modal-store", () => ({
useModalStore: (sel?: (s: unknown) => unknown) => {
const state = { activeModal: "budget", close: mockClose }
// Budget modal is now an independent overlay: it reads budgetOpen and
// dismisses via closeBudget (both mapped to mockClose for assertions).
const state = {
budgetOpen: true,
closeBudget: mockClose,
activeModal: "budget",
close: mockClose,
}
return sel ? sel(state) : state
},
}))
Expand Down
2 changes: 1 addition & 1 deletion src/lib/__tests__/node-search-input.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const { mockSearchNodes } = vi.hoisted(() => ({
}))

vi.mock("@/lib/graph-api", () => ({
searchNodes: (...args: unknown[]) => mockSearchNodes(...args),
searchNodesForEdge: (...args: unknown[]) => mockSearchNodes(...args),
}))

vi.mock("@/lib/node-display", () => ({
Expand Down
16 changes: 12 additions & 4 deletions src/lib/__tests__/settings-modal-removed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,23 @@ beforeEach(() => {

describe("modal-store – settings removed", () => {
it("valid modal ids do not include 'settings'", () => {
// Open each remaining valid modal and verify they work
const validIds = ["add", "budget", "editNode"] as const

for (const id of validIds) {
// "add" and "editNode" are activeModal values.
const activeIds = ["add", "editNode"] as const
for (const id of activeIds) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
useModalStore.getState().open(id as any)
expect(useModalStore.getState().activeModal).toBe(id)
useModalStore.getState().close()
}

// "budget" is an independent overlay (so it can sit on top of another modal
// without closing it) — open() routes it to budgetOpen, not activeModal.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
useModalStore.getState().open("budget" as any)
expect(useModalStore.getState().budgetOpen).toBe(true)
expect(useModalStore.getState().activeModal).toBeNull()
useModalStore.getState().close()
expect(useModalStore.getState().budgetOpen).toBe(false)
})

it("activeModal starts as null after close", () => {
Expand Down
32 changes: 32 additions & 0 deletions src/lib/graph-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,38 @@ export async function searchNodes(
)
}

// Lightweight node search for the edge-picker autocomplete. Hits the dedicated
// /v2/nodes/search endpoint, which returns matches ONLY (no 1-hop neighbour
// expansion, no edges) and just {node_type, ref_id, title}. Unlike /v2/nodes
// this route is free on boltwall, so it's safe to call per keystroke.
//
// The lite payload is adapted to the GraphNode shape (title -> properties.name)
// so existing consumers (NodeSearchInput, resolveNodeTitle) keep working without
// any special-casing.
export async function searchNodesForEdge(
query: string,
opts?: { limit?: number; node_type?: string },
signal?: AbortSignal
): Promise<NodesListResponse> {
const params = new URLSearchParams({
q: query,
limit: String(opts?.limit ?? 10),
})
if (opts?.node_type) params.set("node_type", opts.node_type)

const res = await api.get<{
nodes: { node_type: string; ref_id: string; title: string | null }[]
}>(`/v2/nodes/search?${params}`, undefined, signal)

return {
nodes: (res.nodes ?? []).map((n) => ({
ref_id: n.ref_id,
node_type: n.node_type,
properties: { name: n.title ?? "" },
})),
}
}

// Latest 100 nodes added to the graph + their 1-hop edges. Used to populate
// the canvas on initial mount before the user has issued a search.
// `skip_cache=1` bypasses the backend's Redis response cache so we get a
Expand Down
24 changes: 22 additions & 2 deletions src/stores/modal-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ export type AddTab = "source" | "node" | "edge"

interface ModalState {
activeModal: ModalId
// The budget/top-up modal is an independent OVERLAY (not part of activeModal)
// so it can appear on top of another modal — e.g. when an in-form paid action
// hits a 402 — without closing it. Keeping it separate means the underlying
// form (Add Edge, Add Node, …) stays mounted and keeps its progress.
budgetOpen: boolean
// Which tab the unified Add modal opens on. Persisted across the modal's
// lifetime so deep-links (e.g. "Add Edge" from a node) can target a tab.
addTab: AddTab
Expand All @@ -18,20 +23,35 @@ interface ModalState {
setAddTab: (tab: AddTab) => void
openEdit: (node: GraphNode) => void
openAddEdge: (sourceNode?: GraphNode) => void
openBudget: () => void
closeBudget: () => void
close: () => void
}

export const useModalStore = create<ModalState>((set) => ({
activeModal: null,
budgetOpen: false,
addTab: "source",
editingNode: null,
sourceNode: null,
open: (activeModal) => set({ activeModal }),
// Route "budget" to the overlay so existing open("budget") callers show it on
// top of whatever is open instead of replacing it.
open: (id) => set(id === "budget" ? { budgetOpen: true } : { activeModal: id }),
openAdd: (tab) => set({ activeModal: "add", addTab: tab ?? "source", sourceNode: null }),
setAddTab: (tab) => set({ addTab: tab }),
openEdit: (node) => set({ activeModal: "editNode", editingNode: node }),
openAddEdge: (sourceNode?: GraphNode) =>
set({ activeModal: "add", addTab: "edge", sourceNode: sourceNode ?? null }),
openBudget: () => set({ budgetOpen: true }),
// Dismiss only the budget overlay, leaving any underlying modal (and its
// in-progress form state) intact.
closeBudget: () => set({ budgetOpen: false }),
close: () =>
set({ activeModal: null, addTab: "source", editingNode: null, sourceNode: null }),
set({
activeModal: null,
addTab: "source",
editingNode: null,
sourceNode: null,
budgetOpen: false,
}),
}))
Loading