diff --git a/src/components/layout/connections-section.tsx b/src/components/layout/connections-section.tsx index fcc310e..8df6098 100644 --- a/src/components/layout/connections-section.tsx +++ b/src/components/layout/connections-section.tsx @@ -1,25 +1,34 @@ "use client" import { useMemo, useState } from "react" +import { Trash2 } from "lucide-react" import { Badge } from "@/components/ui/badge" import { useGraphStore } from "@/stores/graph-store" +import { useUserStore } from "@/stores/user-store" +import { useModalStore } from "@/stores/modal-store" import { pickString, DISPLAY_KEY_FALLBACKS } from "@/lib/node-display" import { displayNodeType } from "@/lib/utils" +import { deleteEdge } from "@/lib/graph-api" import type { SchemaNode } from "@/app/ontology/page" import type { GraphNode } from "@/lib/graph-api" interface ConnectionsSectionProps { nodeRefId: string schemas: SchemaNode[] + currentNode?: GraphNode onNavigate?: (node: GraphNode) => void } type GroupBy = "edge_type" | "node_type" -export function ConnectionsSection({ nodeRefId, schemas, onNavigate }: ConnectionsSectionProps) { +export function ConnectionsSection({ nodeRefId, schemas, currentNode, onNavigate }: ConnectionsSectionProps) { const [groupBy, setGroupBy] = useState("edge_type") + const [confirmingDelete, setConfirmingDelete] = useState(null) const nodes = useGraphStore((s) => s.nodes) const edges = useGraphStore((s) => s.edges) + const removeEdge = useGraphStore((s) => s.removeEdge) + const isAdmin = useUserStore((s) => s.isAdmin) + const openAddEdge = useModalStore((s) => s.openAddEdge) const connections = useMemo(() => { const nodeMap = new Map(nodes.map((n) => [n.ref_id, n])) @@ -29,7 +38,7 @@ export function ConnectionsSection({ nodeRefId, schemas, onNavigate }: Connectio const peerId = e.source === nodeRefId ? e.target : e.source const peer = nodeMap.get(peerId) if (!peer) return [] - return [{ edge_type: e.edge_type, peer }] + return [{ edge_type: e.edge_type, peer, edge_ref_id: e.ref_id }] }) }, [edges, nodes, nodeRefId]) @@ -59,6 +68,17 @@ export function ConnectionsSection({ nodeRefId, schemas, onNavigate }: Connectio return title ?? peer.ref_id } + async function handleConfirmDelete(edgeRefId: string) { + if (!isAdmin) return + try { + await deleteEdge(edgeRefId) + } catch { + // Best-effort — remove locally regardless + } + removeEdge(edgeRefId) + setConfirmingDelete(null) + } + return (
{/* Header */} @@ -66,27 +86,38 @@ export function ConnectionsSection({ nodeRefId, schemas, onNavigate }: Connectio

Connections

-
- - +
+ {currentNode && ( + + )} +
+ + +
@@ -101,21 +132,57 @@ export function ConnectionsSection({ nodeRefId, schemas, onNavigate }: Connectio {groupBy === "node_type" ? displayNodeType(groupKey) : groupKey}{" "} ({conns.length})

- {conns.map((conn, i) => ( - - ))} + + {isAdmin && conn.edge_ref_id !== undefined && ( + isConfirming ? ( +
+ Remove? + + +
+ ) : ( + + ) + )} +
+ ) + })} ))} diff --git a/src/components/layout/node-preview-panel.tsx b/src/components/layout/node-preview-panel.tsx index e57a26b..bfe44c6 100644 --- a/src/components/layout/node-preview-panel.tsx +++ b/src/components/layout/node-preview-panel.tsx @@ -1647,7 +1647,7 @@ export function NodePreviewPanel({ node, onBack, schemas }: NodePreviewPanelProp {/* Connections — always visible regardless of unlock state */}
- +
diff --git a/src/components/layout/toolkit.tsx b/src/components/layout/toolkit.tsx index b22d926..8910d10 100644 --- a/src/components/layout/toolkit.tsx +++ b/src/components/layout/toolkit.tsx @@ -10,6 +10,7 @@ import { Network, Boxes, BookMarked, + BookText, ClipboardList, Heart, Menu, @@ -184,6 +185,7 @@ export function Toolkit({ openAdd("source")} /> + openAdd("node", "Lingo")} /> openAdd("source"), active: false }, + { icon: BookText, label: "Add Lingo Node", action: () => openAdd("node", "Lingo"), active: false }, { icon: MessageSquare, label: "Graph Agent", action: onToggleAgent ?? (() => {}), active: agentOpen ?? false }, { icon: BookMarked, label: "My Content", action: onToggleMyContent, active: myContentOpen }, { icon: Heart, label: "Following", action: onToggleFollowing, active: followingOpen }, diff --git a/src/components/modals/add-edge-form.tsx b/src/components/modals/add-edge-form.tsx index 752674e..882b5e1 100644 --- a/src/components/modals/add-edge-form.tsx +++ b/src/components/modals/add-edge-form.tsx @@ -8,8 +8,9 @@ import { NodeSearchInput } from "@/components/ui/node-search-input" import { useModalStore } from "@/stores/modal-store" import { useSchemaStore } from "@/stores/schema-store" import { useUserStore } from "@/stores/user-store" +import { useGraphStore } from "@/stores/graph-store" import { getPrice, payL402 } from "@/lib/sphinx" -import { createEdge, type GraphNode } from "@/lib/graph-api" +import { createEdge, type GraphNode, type GraphEdge } from "@/lib/graph-api" import { displayNodeType } from "@/lib/utils" type Status = "idle" | "submitting" | "success" | "error" @@ -51,6 +52,7 @@ export function AddEdgeForm() { const close = useModalStore((s) => s.close) const openModal = useModalStore((s) => s.open) const setBudget = useUserStore((s) => s.setBudget) + const addNodes = useGraphStore((s) => s.addNodes) const schemaEdges = useSchemaStore((s) => s.edges) @@ -192,7 +194,7 @@ export function AddEdgeForm() { } const doCreate = async () => { - await createEdge({ + const result = await createEdge({ source: selectedSource.ref_id, target: selectedTarget.ref_id, edge_type: edgeType, @@ -201,6 +203,16 @@ export function AddEdgeForm() { // schema types don't need it (the schema already exists). ...(customMode ? { create_schema_if_missing: true } : {}), }) + // Reflect the new edge in the graph store immediately so ConnectionsSection + // updates without a reload. + const createdEdge: GraphEdge = { + source: selectedSource.ref_id, + target: selectedTarget.ref_id, + edge_type: edgeType, + // Backend may return ref_id on the created edge + ref_id: (result as { ref_id?: string } | null)?.ref_id, + } + addNodes([], [createdEdge]) setStatus("success") setTimeout(() => close(), 1500) } diff --git a/src/components/modals/add-node-form.tsx b/src/components/modals/add-node-form.tsx index 3e2e669..90cf384 100644 --- a/src/components/modals/add-node-form.tsx +++ b/src/components/modals/add-node-form.tsx @@ -71,11 +71,12 @@ function parseFieldValue(type: string, raw: string): unknown { export function AddNodeForm() { const { close } = useModalStore() + const preselectedNodeType = useModalStore((s) => s.preselectedNodeType) const setBudget = useUserStore((s) => s.setBudget) const pubKey = useUserStore((s) => s.pubKey) const schemas = useSchemaStore((s) => s.schemas) - const [selectedType, setSelectedType] = useState("") + const [selectedType, setSelectedType] = useState(preselectedNodeType ?? "") const [fieldValues, setFieldValues] = useState>({}) const [domains, setDomains] = useState(null) const [price, setPrice] = useState(null) diff --git a/src/lib/__tests__/add-edge-modal.test.tsx b/src/lib/__tests__/add-edge-modal.test.tsx index d47f4e3..1b47bd4 100644 --- a/src/lib/__tests__/add-edge-modal.test.tsx +++ b/src/lib/__tests__/add-edge-modal.test.tsx @@ -34,6 +34,13 @@ vi.mock("@/stores/user-store", () => ({ sel({ setBudget: vi.fn() }), })) +// Graph store — AddEdgeForm calls addNodes on success. +const mockAddNodes = vi.fn() +vi.mock("@/stores/graph-store", () => ({ + useGraphStore: (sel: (s: Record) => unknown) => + sel({ addNodes: mockAddNodes }), +})) + // --------------------------------------------------------------------------- // Fixture nodes // --------------------------------------------------------------------------- @@ -343,6 +350,29 @@ describe("AddEdgeForm", () => { }) }) + it("calls addNodes([], [createdEdge]) on successful edge creation", async () => { + mockCreateEdge.mockResolvedValueOnce({ ref_id: "new-edge-ref" }) + 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("HAS_TOPIC")) + await userEvent.click(screen.getByRole("button", { name: /add edge/i })) + await waitFor(() => expect(mockAddNodes).toHaveBeenCalled()) + expect(mockAddNodes).toHaveBeenCalledWith( + [], + expect.arrayContaining([ + expect.objectContaining({ + source: "node-source-ref", + target: "node-target-ref", + edge_type: "HAS_TOPIC", + }), + ]) + ) + }) + it("calls close after success auto-close timeout", async () => { withSource(null) render() diff --git a/src/lib/__tests__/connections-section.test.tsx b/src/lib/__tests__/connections-section.test.tsx index 7828018..8f013e8 100644 --- a/src/lib/__tests__/connections-section.test.tsx +++ b/src/lib/__tests__/connections-section.test.tsx @@ -1,15 +1,40 @@ import { describe, it, expect, vi, beforeEach } from "vitest" -import { render, screen, fireEvent } from "@testing-library/react" +import { render, screen, fireEvent, waitFor } from "@testing-library/react" import React from "react" import type { GraphNode, GraphEdge } from "@/lib/graph-api" // --- mock useGraphStore --- let mockNodes: GraphNode[] = [] let mockEdges: GraphEdge[] = [] +const mockRemoveEdge = vi.fn() vi.mock("@/stores/graph-store", () => ({ - useGraphStore: (sel: (s: { nodes: GraphNode[]; edges: GraphEdge[] }) => unknown) => - sel({ nodes: mockNodes, edges: mockEdges }), + useGraphStore: (sel: (s: { + nodes: GraphNode[] + edges: GraphEdge[] + removeEdge: typeof mockRemoveEdge + }) => unknown) => + sel({ nodes: mockNodes, edges: mockEdges, removeEdge: mockRemoveEdge }), +})) + +// --- mock useUserStore --- +let mockIsAdmin = false +vi.mock("@/stores/user-store", () => ({ + useUserStore: (sel: (s: { isAdmin: boolean }) => unknown) => + sel({ isAdmin: mockIsAdmin }), +})) + +// --- mock useModalStore --- +const mockOpenAddEdge = vi.fn() +vi.mock("@/stores/modal-store", () => ({ + useModalStore: (sel: (s: { openAddEdge: typeof mockOpenAddEdge }) => unknown) => + sel({ openAddEdge: mockOpenAddEdge }), +})) + +// --- mock deleteEdge --- +const mockDeleteEdge = vi.fn().mockResolvedValue(undefined) +vi.mock("@/lib/graph-api", () => ({ + deleteEdge: (...args: unknown[]) => mockDeleteEdge(...args), })) import { ConnectionsSection } from "@/components/layout/connections-section" @@ -20,13 +45,19 @@ const N3: GraphNode = { ref_id: "n3", node_type: "Topic", properties: { title: " 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_N2_WITH_REF: GraphEdge = { source: "n1", target: "n2", edge_type: "MENTIONS", ref_id: "edge-ref-1" } 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" } +beforeEach(() => { + vi.clearAllMocks() + mockIsAdmin = false + mockDeleteEdge.mockResolvedValue(undefined) +}) + describe("ConnectionsSection – edge type grouping (default)", () => { beforeEach(() => { - vi.clearAllMocks() mockNodes = [N1, N2, N3, N4] mockEdges = [EDGE_MENTIONS_N2, EDGE_MENTIONS_N3, EDGE_CREATED_N4] }) @@ -38,10 +69,8 @@ describe("ConnectionsSection – edge type grouping (default)", () => { 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() }) @@ -70,16 +99,13 @@ describe("ConnectionsSection – node type grouping toggle", () => { 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) }) @@ -110,7 +136,6 @@ describe("ConnectionsSection – empty state", () => { 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] }) @@ -118,7 +143,6 @@ describe("ConnectionsSection – skips peer nodes not in store", () => { 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() }) }) @@ -143,7 +167,93 @@ describe("ConnectionsSection – rows are clickable", () => { render() const row = screen.getByText("Blockchain").closest("button") expect(row).toBeTruthy() - // Clicking without onNavigate should not throw expect(() => fireEvent.click(row!)).not.toThrow() }) }) + +// --------------------------------------------------------------------------- +// New: Add connection button +// --------------------------------------------------------------------------- +describe("ConnectionsSection – Add connection button", () => { + beforeEach(() => { + mockNodes = [N1, N2] + mockEdges = [EDGE_MENTIONS_N2] + }) + + it("shows '+ Add connection' button when currentNode is provided", () => { + render() + expect(screen.getByRole("button", { name: "Add connection" })).toBeInTheDocument() + }) + + it("does not show '+ Add connection' when currentNode is omitted", () => { + render() + expect(screen.queryByRole("button", { name: "Add connection" })).toBeNull() + }) + + it("calls openAddEdge with currentNode when '+ Add connection' is clicked", () => { + render() + fireEvent.click(screen.getByRole("button", { name: "Add connection" })) + expect(mockOpenAddEdge).toHaveBeenCalledTimes(1) + expect(mockOpenAddEdge).toHaveBeenCalledWith(N1) + }) +}) + +// --------------------------------------------------------------------------- +// New: Admin-only trash icon / delete flow +// --------------------------------------------------------------------------- +describe("ConnectionsSection – admin delete", () => { + beforeEach(() => { + mockNodes = [N1, N2] + mockEdges = [EDGE_MENTIONS_N2_WITH_REF] + }) + + it("hides the trash icon when isAdmin=false", () => { + mockIsAdmin = false + render() + expect(screen.queryByRole("button", { name: "Remove connection" })).toBeNull() + }) + + it("shows the trash icon when isAdmin=true and edge has ref_id", () => { + mockIsAdmin = true + render() + expect(screen.getByRole("button", { name: "Remove connection" })).toBeInTheDocument() + }) + + it("clicking trash shows inline confirmation", () => { + mockIsAdmin = true + render() + fireEvent.click(screen.getByRole("button", { name: "Remove connection" })) + expect(screen.getByText("Remove?")).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Confirm remove" })).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Cancel remove" })).toBeInTheDocument() + }) + + it("cancel hides the inline confirmation", () => { + mockIsAdmin = true + render() + fireEvent.click(screen.getByRole("button", { name: "Remove connection" })) + fireEvent.click(screen.getByRole("button", { name: "Cancel remove" })) + expect(screen.queryByText("Remove?")).toBeNull() + expect(screen.getByRole("button", { name: "Remove connection" })).toBeInTheDocument() + }) + + it("confirming calls deleteEdge and removeEdge, then hides confirmation", async () => { + mockIsAdmin = true + render() + fireEvent.click(screen.getByRole("button", { name: "Remove connection" })) + fireEvent.click(screen.getByRole("button", { name: "Confirm remove" })) + + await waitFor(() => { + expect(mockDeleteEdge).toHaveBeenCalledWith("edge-ref-1") + expect(mockRemoveEdge).toHaveBeenCalledWith("edge-ref-1") + }) + expect(screen.queryByText("Remove?")).toBeNull() + }) + + it("hides trash for edges without ref_id even when isAdmin=true", () => { + mockIsAdmin = true + mockEdges = [EDGE_MENTIONS_N2] // no ref_id + render() + expect(screen.queryByRole("button", { name: "Remove connection" })).toBeNull() + }) +}) diff --git a/src/lib/__tests__/graph-store-remove-edge.test.ts b/src/lib/__tests__/graph-store-remove-edge.test.ts new file mode 100644 index 0000000..445ad08 --- /dev/null +++ b/src/lib/__tests__/graph-store-remove-edge.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect, beforeEach } from "vitest" +import { useGraphStore } from "@/stores/graph-store" +import type { GraphEdge } from "@/lib/graph-api" + +const EDGE_A: GraphEdge = { source: "n1", target: "n2", edge_type: "MENTIONS", ref_id: "ref-a" } +const EDGE_B: GraphEdge = { source: "n2", target: "n3", edge_type: "RELATED_TO", ref_id: "ref-b" } +const EDGE_NO_REF: GraphEdge = { source: "n1", target: "n3", edge_type: "ABOUT" } + +beforeEach(() => { + useGraphStore.getState().setGraphData([], [EDGE_A, EDGE_B, EDGE_NO_REF]) +}) + +describe("graph-store – removeEdge", () => { + it("removes the edge with matching ref_id", () => { + useGraphStore.getState().removeEdge("ref-a") + const edges = useGraphStore.getState().edges + expect(edges.some((e) => e.ref_id === "ref-a")).toBe(false) + expect(edges.some((e) => e.ref_id === "ref-b")).toBe(true) + }) + + it("leaves edges without ref_id intact", () => { + useGraphStore.getState().removeEdge("ref-a") + const edges = useGraphStore.getState().edges + expect(edges.some((e) => e.source === "n1" && e.target === "n3")).toBe(true) + }) + + it("no-ops when ref_id does not exist", () => { + useGraphStore.getState().removeEdge("nonexistent") + expect(useGraphStore.getState().edges).toHaveLength(3) + }) + + it("removes only the targeted edge when multiple exist", () => { + useGraphStore.getState().removeEdge("ref-b") + const edges = useGraphStore.getState().edges + expect(edges).toHaveLength(2) + expect(edges.some((e) => e.ref_id === "ref-a")).toBe(true) + }) +}) diff --git a/src/lib/__tests__/settings-modal-removed.test.ts b/src/lib/__tests__/settings-modal-removed.test.ts index 14b9306..e92add7 100644 --- a/src/lib/__tests__/settings-modal-removed.test.ts +++ b/src/lib/__tests__/settings-modal-removed.test.ts @@ -46,3 +46,24 @@ describe("modal-store – settings removed", () => { expect(useModalStore.getState().activeModal).toBeNull() }) }) + +describe("modal-store – preselectedNodeType", () => { + it("openAdd('node', 'Lingo') sets preselectedNodeType to 'Lingo'", () => { + useModalStore.getState().openAdd("node", "Lingo") + expect(useModalStore.getState().preselectedNodeType).toBe("Lingo") + expect(useModalStore.getState().addTab).toBe("node") + expect(useModalStore.getState().activeModal).toBe("add") + }) + + it("close() resets preselectedNodeType to null", () => { + useModalStore.getState().openAdd("node", "Lingo") + useModalStore.getState().close() + expect(useModalStore.getState().preselectedNodeType).toBeNull() + }) + + it("openAdd() without nodeType sets preselectedNodeType to null", () => { + useModalStore.getState().openAdd("node", "Lingo") + useModalStore.getState().openAdd("source") + expect(useModalStore.getState().preselectedNodeType).toBeNull() + }) +}) diff --git a/src/lib/__tests__/toolkit-fab.test.tsx b/src/lib/__tests__/toolkit-fab.test.tsx index c67624c..58e1971 100644 --- a/src/lib/__tests__/toolkit-fab.test.tsx +++ b/src/lib/__tests__/toolkit-fab.test.tsx @@ -228,3 +228,38 @@ describe("Toolkit (desktop strip)", () => { expect(modalOpen).not.toHaveBeenCalledWith("settings") }) }) + +// ── "Add Lingo Node" tests ──────────────────────────────────────────────────── + +describe("ToolkitFAB – Add Lingo Node", () => { + it("shows 'Add Lingo Node' action in FAB popup", async () => { + const { ToolkitFAB } = await import("@/components/layout/toolkit") + render() + fireEvent.click(screen.getByRole("button", { name: "Open menu" })) + expect(screen.getByText("Add Lingo Node")).toBeInTheDocument() + }) + + it("clicking 'Add Lingo Node' calls openAdd('node', 'Lingo') and closes popup", async () => { + const { ToolkitFAB } = await import("@/components/layout/toolkit") + render() + fireEvent.click(screen.getByRole("button", { name: "Open menu" })) + fireEvent.click(screen.getByText("Add Lingo Node")) + expect(modalOpenAdd).toHaveBeenCalledWith("node", "Lingo") + expect(screen.queryByText("Add Lingo Node")).not.toBeInTheDocument() + }) +}) + +describe("Toolkit (desktop) – Add Lingo Node", () => { + it("renders 'Add Lingo Node' button", async () => { + const { Toolkit } = await import("@/components/layout/toolkit") + render() + expect(screen.getByRole("button", { name: "Add Lingo Node" })).toBeInTheDocument() + }) + + it("clicking 'Add Lingo Node' calls openAdd('node', 'Lingo')", async () => { + const { Toolkit } = await import("@/components/layout/toolkit") + render() + fireEvent.click(screen.getByRole("button", { name: "Add Lingo Node" })) + expect(modalOpenAdd).toHaveBeenCalledWith("node", "Lingo") + }) +}) diff --git a/src/stores/graph-store.ts b/src/stores/graph-store.ts index cfbf4e9..815e009 100644 --- a/src/stores/graph-store.ts +++ b/src/stores/graph-store.ts @@ -25,6 +25,7 @@ interface GraphState { setSidebarSelectedNode: (node: GraphNode | null) => void beginNeighborLoad: (refId: string) => void endNeighborLoad: (refId: string) => void + removeEdge: (edgeRefId: string) => void clearSelection: () => void returnTo: string | null setReturnTo: (url: string | null) => void @@ -75,6 +76,10 @@ export const useGraphStore = create((set) => ({ next.delete(refId) return { loadingNeighborRefs: next } }), + removeEdge: (edgeRefId) => + set((s) => ({ + edges: s.edges.filter((e) => e.ref_id !== edgeRefId), + })), clearSelection: () => set({ selectedNode: null, sidebarSelectedNode: null, hoveredNode: null }), returnTo: null, diff --git a/src/stores/modal-store.ts b/src/stores/modal-store.ts index 1e8edd3..f41cb66 100644 --- a/src/stores/modal-store.ts +++ b/src/stores/modal-store.ts @@ -18,8 +18,11 @@ interface ModalState { addTab: AddTab editingNode: GraphNode | null sourceNode: GraphNode | null + // Pre-selected node type for the Add Node form — allows callers to skip the + // type-selection step (e.g. "Add Lingo Node" toolbar shortcut). + preselectedNodeType: string | null open: (id: ModalId) => void - openAdd: (tab?: AddTab) => void + openAdd: (tab?: AddTab, nodeType?: string) => void setAddTab: (tab: AddTab) => void openEdit: (node: GraphNode) => void openAddEdge: (sourceNode?: GraphNode) => void @@ -34,10 +37,17 @@ export const useModalStore = create((set) => ({ addTab: "source", editingNode: null, sourceNode: null, + preselectedNodeType: null, // 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 }), + openAdd: (tab, nodeType) => + set({ + activeModal: "add", + addTab: tab ?? "source", + sourceNode: null, + preselectedNodeType: nodeType ?? null, + }), setAddTab: (tab) => set({ addTab: tab }), openEdit: (node) => set({ activeModal: "editNode", editingNode: node }), openAddEdge: (sourceNode?: GraphNode) => @@ -52,6 +62,7 @@ export const useModalStore = create((set) => ({ addTab: "source", editingNode: null, sourceNode: null, + preselectedNodeType: null, budgetOpen: false, }), }))