From d365e9d76d093e18146e810640a4102b715a6182 Mon Sep 17 00:00:00 2001 From: Rassl Date: Fri, 19 Jun 2026 04:23:39 +0400 Subject: [PATCH] feat: allow adding new edge types --- src/app/ontology/page.tsx | 9 + src/components/modals/add-edge-form.tsx | 300 +++++++++++++++++- src/components/modals/budget-modal.tsx | 12 +- src/components/ui/node-search-input.tsx | 4 +- src/lib/__tests__/add-edge-modal.test.tsx | 97 +++++- src/lib/__tests__/budget-modal.test.tsx | 9 +- src/lib/__tests__/node-search-input.test.tsx | 2 +- .../__tests__/settings-modal-removed.test.ts | 16 +- src/lib/graph-api.ts | 32 ++ src/stores/modal-store.ts | 24 +- 10 files changed, 469 insertions(+), 36 deletions(-) diff --git a/src/app/ontology/page.tsx b/src/app/ontology/page.tsx index 6815304..c5673ac 100644 --- a/src/app/ontology/page.tsx +++ b/src/app/ontology/page.tsx @@ -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 } export default function OntologyPage() { diff --git a/src/components/modals/add-edge-form.tsx b/src/components/modals/add-edge-form.tsx index ad471a5..752674e 100644 --- a/src/components/modals/add-edge-form.tsx +++ b/src/components/modals/add-edge-form.tsx @@ -1,7 +1,7 @@ "use client" -import { useCallback, useEffect, useMemo, useState } from "react" -import { CheckCircle } from "lucide-react" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { CheckCircle, Plus, X } from "lucide-react" import { Button } from "@/components/ui/button" import { SelectCustom } from "@/components/ui/select-custom" import { NodeSearchInput } from "@/components/ui/node-search-input" @@ -10,9 +10,42 @@ import { useSchemaStore } from "@/stores/schema-store" import { useUserStore } from "@/stores/user-store" import { getPrice, payL402 } from "@/lib/sphinx" import { createEdge, type GraphNode } from "@/lib/graph-api" +import { displayNodeType } from "@/lib/utils" type Status = "idle" | "submitting" | "success" | "error" +interface EdgeField { + key: string + type: string + required: boolean +} + +// Schema-config keys that live alongside real attribute definitions on an edge +// schema but are NOT user-editable instance properties — skip them when +// building the property form. +const EDGE_META_KEYS = new Set([ + "display_name", + "cardinality", + "volatility", + "decay_curve", + "temporal", + "half_life", + "allow_hard_ttl", +]) + +// Turn an edge schema's `attributes` map ({ since: "?datetime", role: "string" }) +// into renderable form fields. A leading "?" marks the attribute optional. +function parseEdgeFields(attrs: Record | undefined): EdgeField[] { + if (!attrs) return [] + return Object.entries(attrs) + .filter(([k, v]) => !EDGE_META_KEYS.has(k) && typeof v === "string") + .map(([key, v]) => ({ + key, + type: v.startsWith("?") ? v.slice(1) : v, + required: !v.startsWith("?"), + })) +} + export function AddEdgeForm() { const storeSourceNode = useModalStore((s) => s.sourceNode) const close = useModalStore((s) => s.close) @@ -40,21 +73,89 @@ export function AddEdgeForm() { ) const [selectedTarget, setSelectedTarget] = useState(null) const [edgeType, setEdgeType] = useState("") + // When on, the user types a brand-new relationship type instead of picking a + // schema-defined one; the backend auto-creates the edge schema for it + // (create_schema_if_missing). This is how edges are added without being tied + // to the existing ontology. + const [customMode, setCustomMode] = useState(false) + // Property values keyed by attribute name (schema-driven fields). + const [edgeData, setEdgeData] = useState>({}) + // Free-form key/value rows, used for custom types or schema edges that + // define no attributes. Each row carries a stable id for React keys. + const [customRows, setCustomRows] = useState< + { id: string; key: string; value: string }[] + >([]) + const rowIdRef = useRef(0) const [status, setStatus] = useState("idle") const [errorMsg, setErrorMsg] = useState(null) - // Derive unique edge types excluding CHILD_OF + // Derive unique edge types valid for the selected source/target node types. + // + // Edge schemas are directional (source_type -> target_type) and the backend + // validates the chosen edge_type against the *specific* source/target node + // types (get_schema_edge_by_edge_type). Offering every edge type regardless + // of the picked nodes lets a user select e.g. FOUND_AT for a pair it isn't + // defined for, which the backend then rejects with "Invalid edge type". So we + // filter to the types whose schema endpoints match the selected nodes, + // honouring the "*" wildcard. An unselected endpoint imposes no constraint. + // (Match on source_type/target_type — `source`/`target` are schema ref_ids.) + const srcType = selectedSource?.node_type + const tgtType = selectedTarget?.node_type + const matchesPair = useCallback( + (e: { source_type?: string; target_type?: string }) => { + const ok = (schemaType: string | undefined, selected: string | undefined) => + !selected || schemaType === "*" || schemaType === selected + return ok(e.source_type, srcType) && ok(e.target_type, tgtType) + }, + [srcType, tgtType] + ) const edgeTypeOptions = useMemo(() => { const seen = new Set() const options: { value: string; label: string }[] = [] for (const e of schemaEdges) { - if (e.edge_type && e.edge_type !== "CHILD_OF" && !seen.has(e.edge_type)) { - seen.add(e.edge_type) - options.push({ value: e.edge_type, label: e.edge_type }) - } + if (!e.edge_type || e.edge_type === "CHILD_OF") continue + if (!matchesPair(e)) continue + if (seen.has(e.edge_type)) continue + seen.add(e.edge_type) + options.push({ value: e.edge_type, label: e.edge_type }) } return options.sort((a, b) => a.label.localeCompare(b.label)) - }, [schemaEdges]) + }, [schemaEdges, matchesPair]) + + // Attribute fields for the currently selected (schema) edge type, scoped to + // the chosen source/target pair so we read the right schema definition. + const edgeFields = useMemo(() => { + if (customMode || !edgeType) return [] + const match = schemaEdges.find( + (e) => e.edge_type === edgeType && matchesPair(e) + ) + return parseEdgeFields(match?.attributes) + }, [schemaEdges, edgeType, customMode, matchesPair]) + + // Reset entered properties whenever the field set changes (edge type / nodes + // changed, or toggled custom mode) so stale values don't leak across types. + useEffect(() => { + /* eslint-disable react-hooks/set-state-in-effect */ + setEdgeData({}) + setCustomRows([]) + /* eslint-enable react-hooks/set-state-in-effect */ + }, [edgeType, customMode]) + + // Whether to show the free-form key/value editor instead of schema fields: + // custom (off-schema) types, or schema types that declare no attributes. + const useFreeForm = !!edgeType && (customMode || edgeFields.length === 0) + + // Clear a previously chosen edge type once it's no longer valid for the + // currently selected node types (e.g. the user changed a node afterwards). + // Skipped in custom mode, where a free-typed value is intentionally off-schema. + useEffect(() => { + if (!customMode && edgeType && !edgeTypeOptions.some((o) => o.value === edgeType)) { + // Pruning a now-invalid selection back to the resting empty state — a + // sync with the derived options list, not a cascading render. + /* eslint-disable-next-line react-hooks/set-state-in-effect */ + setEdgeType("") + } + }, [edgeTypeOptions, edgeType, customMode]) const handleSubmit = useCallback( async (e: React.FormEvent) => { @@ -65,11 +166,40 @@ export function AddEdgeForm() { return } + // Assemble optional edge properties from whichever editor is active. + const edge_data: Record = {} + if (useFreeForm) { + for (const row of customRows) { + const k = row.key.trim() + if (k) edge_data[k] = row.value + } + } else { + const missing = edgeFields.filter( + (f) => f.required && !(edgeData[f.key] ?? "").trim() + ) + if (missing.length > 0) { + setErrorMsg( + `Missing required ${missing.length === 1 ? "property" : "properties"}: ${missing + .map((m) => m.key) + .join(", ")}` + ) + return + } + for (const f of edgeFields) { + const v = (edgeData[f.key] ?? "").trim() + if (v) edge_data[f.key] = v + } + } + const doCreate = async () => { await createEdge({ source: selectedSource.ref_id, target: selectedTarget.ref_id, edge_type: edgeType, + ...(Object.keys(edge_data).length > 0 ? { edge_data } : {}), + // Opt in to schema-on-write only for free-typed types. Existing + // schema types don't need it (the schema already exists). + ...(customMode ? { create_schema_if_missing: true } : {}), }) setStatus("success") setTimeout(() => close(), 1500) @@ -89,6 +219,11 @@ export function AddEdgeForm() { await payL402(setBudget) await doCreate() } catch { + // Couldn't settle the invoice (e.g. not enough sats). Surface the + // top-up overlay and re-enable the form so the user can retry once + // funded — leaving status as "submitting" would wedge the button on + // "Creating…" with no way forward. + setStatus("idle") openModal("budget") } return @@ -104,7 +239,19 @@ export function AddEdgeForm() { } } }, - [selectedSource, selectedTarget, edgeType, close, openModal, setBudget] + [ + selectedSource, + selectedTarget, + edgeType, + customMode, + useFreeForm, + edgeFields, + edgeData, + customRows, + close, + openModal, + setBudget, + ] ) const busy = status === "submitting" || status === "success" @@ -140,12 +287,44 @@ export function AddEdgeForm() { {/* Edge type */}
- - {edgeTypeOptions.length === 0 ? ( +
+ + +
+ {customMode ? ( + <> + { + // Match the backend's normalization: upper-case, spaces -> _ + setEdgeType(e.target.value.toUpperCase().replace(/\s+/g, "_")) + setErrorMsg(null) + }} + placeholder="e.g. FOUND_AT" + disabled={busy} + className="w-full rounded-md border border-border/50 bg-muted/50 h-10 px-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary/40 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50" + /> +

+ A new relationship type will be added to the schema for this node pair. +

+ + ) : edgeTypeOptions.length === 0 ? (
- No edge types available. Load schemas first. + {schemaEdges.length === 0 + ? "No edge types available. Load schemas first." + : selectedSource && selectedTarget + ? `No relationships defined from ${displayNodeType(selectedSource.node_type)} to ${displayNodeType(selectedTarget.node_type)}. Use “+ Custom type” to add one.` + : "No relationship types match the selected node type."}
) : ( + {/* Edge properties */} + {edgeType && !useFreeForm && edgeFields.length > 0 && ( +
+ + {edgeFields.map((f) => ( +
+ + {f.key} + {f.required && *} + ({f.type}) + + { + const v = e.target.value + setEdgeData((d) => ({ ...d, [f.key]: v })) + setErrorMsg(null) + }} + placeholder={f.type} + disabled={busy} + className="w-full rounded-md border border-border/50 bg-muted/50 h-9 px-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary/40 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50" + /> +
+ ))} +
+ )} + + {/* Free-form properties (custom types or schema types with no attributes) */} + {edgeType && useFreeForm && ( +
+ + {customRows.map((row, i) => ( +
+ { + const v = e.target.value + setCustomRows((rows) => + rows.map((r, j) => (j === i ? { ...r, key: v } : r)) + ) + setErrorMsg(null) + }} + placeholder="key" + disabled={busy} + className="w-1/3 rounded-md border border-border/50 bg-muted/50 h-9 px-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary/40 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50" + /> + { + const v = e.target.value + setCustomRows((rows) => + rows.map((r, j) => (j === i ? { ...r, value: v } : r)) + ) + setErrorMsg(null) + }} + placeholder="value" + disabled={busy} + className="flex-1 rounded-md border border-border/50 bg-muted/50 h-9 px-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary/40 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50" + /> + +
+ ))} + +
+ )} + {/* Error */} {errorMsg && (

{errorMsg}

diff --git a/src/components/modals/budget-modal.tsx b/src/components/modals/budget-modal.tsx index 8c1cbaa..2d7e0a6 100644 --- a/src/components/modals/budget-modal.tsx +++ b/src/components/modals/budget-modal.tsx @@ -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) @@ -174,8 +174,8 @@ export function BudgetModal() { }, []) useEffect(() => { - if (activeModal !== "budget") resetState() - }, [activeModal, resetState]) + if (!budgetOpen) resetState() + }, [budgetOpen, resetState]) useEffect(() => { return () => { @@ -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 } @@ -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, @@ -553,7 +553,7 @@ export function BudgetModal() { const successDelta = amount ?? (reachedViaFirstPurchase ? firstPurchaseAmount : null) return ( - close()}> + closeBudget()}> diff --git a/src/components/ui/node-search-input.tsx b/src/components/ui/node-search-input.tsx index de13c4e..fac4b56 100644 --- a/src/components/ui/node-search-input.tsx +++ b/src/components/ui/node-search-input.tsx @@ -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" @@ -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) diff --git a/src/lib/__tests__/add-edge-modal.test.tsx b/src/lib/__tests__/add-edge-modal.test.tsx index cb5eb84..d47f4e3 100644 --- a/src/lib/__tests__/add-edge-modal.test.tsx +++ b/src/lib/__tests__/add-edge-modal.test.tsx @@ -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", () => ({ @@ -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", () => ({ @@ -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() + 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() + 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() + 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() + // 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() + }) }) // ------------------------------------------------------------------------- @@ -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 () => { diff --git a/src/lib/__tests__/budget-modal.test.tsx b/src/lib/__tests__/budget-modal.test.tsx index 6656444..8fac8e6 100644 --- a/src/lib/__tests__/budget-modal.test.tsx +++ b/src/lib/__tests__/budget-modal.test.tsx @@ -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 }, })) diff --git a/src/lib/__tests__/node-search-input.test.tsx b/src/lib/__tests__/node-search-input.test.tsx index 1b4197e..7ad9b14 100644 --- a/src/lib/__tests__/node-search-input.test.tsx +++ b/src/lib/__tests__/node-search-input.test.tsx @@ -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", () => ({ diff --git a/src/lib/__tests__/settings-modal-removed.test.ts b/src/lib/__tests__/settings-modal-removed.test.ts index 1939f5c..14b9306 100644 --- a/src/lib/__tests__/settings-modal-removed.test.ts +++ b/src/lib/__tests__/settings-modal-removed.test.ts @@ -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", () => { diff --git a/src/lib/graph-api.ts b/src/lib/graph-api.ts index b97286b..9728527 100644 --- a/src/lib/graph-api.ts +++ b/src/lib/graph-api.ts @@ -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 { + 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 diff --git a/src/stores/modal-store.ts b/src/stores/modal-store.ts index 07fbf17..1e8edd3 100644 --- a/src/stores/modal-store.ts +++ b/src/stores/modal-store.ts @@ -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 @@ -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((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, + }), }))