- {open && (
-
-
- {options.map((opt) => (
+ {/* Rendered in a portal so it floats above (and is never clipped by) any
+ scroll container such as a modal body. */}
+
setOpen(false)}
+ className="min-w-[120px] rounded-lg border border-border/50 bg-popover py-1 shadow-lg shadow-black/20"
+ >
+ {searchable && (
+
+
+ setQuery(e.target.value)}
+ placeholder="Search…"
+ className="h-7 w-full rounded-md border border-border/50 bg-muted/40 pl-7 pr-2 text-xs text-foreground placeholder:text-muted-foreground focus:border-primary/40 focus:outline-none"
+ />
+
+ )}
+
+ {filtered.length === 0 ? (
+
No matches
+ ) : (
+ filtered.map((opt) => (
{opt.label}
- ))}
-
+ ))
+ )}
- )}
+
)
}
diff --git a/src/lib/__tests__/add-content-modal.test.tsx b/src/lib/__tests__/add-content-modal.test.tsx
index c6e8dda1..9d68ef09 100644
--- a/src/lib/__tests__/add-content-modal.test.tsx
+++ b/src/lib/__tests__/add-content-modal.test.tsx
@@ -1,4 +1,4 @@
-import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
+import { describe, it, expect, vi, beforeEach } from "vitest"
import { render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import React from "react"
@@ -136,7 +136,7 @@ const mockNode = {
properties: {},
}
-import { AddContentModal } from "@/components/modals/add-content-modal"
+import { AddSourceForm } from "@/components/modals/add-source-form"
describe("AddContentModal — preview probe", () => {
beforeEach(() => {
@@ -159,7 +159,7 @@ describe("AddContentModal — preview probe", () => {
})
mockApiGet.mockResolvedValue({ nodes: [mockNode] })
- render(
)
+ render(
)
const input = screen.getByPlaceholderText(/Paste URL/)
await userEvent.type(input, "https://youtube.com/watch?v=test123")
@@ -188,7 +188,7 @@ describe("AddContentModal — preview probe", () => {
})
mockApiGet.mockRejectedValue(new Response(null, { status: 402 }))
- render(
)
+ render(
)
const input = screen.getByPlaceholderText(/Paste URL/)
await userEvent.type(input, "https://youtube.com/watch?v=test123")
@@ -217,7 +217,7 @@ describe("AddContentModal — preview probe", () => {
})
mockApiGet.mockRejectedValue(new Error("Network failure"))
- render(
)
+ render(
)
const input = screen.getByPlaceholderText(/Paste URL/)
await userEvent.type(input, "https://youtube.com/watch?v=test123")
@@ -242,7 +242,7 @@ describe("AddContentModal — preview probe", () => {
status: "in_progress",
})
- render(
)
+ render(
)
const input = screen.getByPlaceholderText(/Paste URL/)
await userEvent.type(input, "https://youtube.com/watch?v=test123")
@@ -262,7 +262,7 @@ describe("AddContentModal — preview probe", () => {
status: null,
})
- render(
)
+ render(
)
const input = screen.getByPlaceholderText(/Paste URL/)
await userEvent.type(input, "https://youtube.com/watch?v=test123")
@@ -278,7 +278,7 @@ describe("AddContentModal — preview probe", () => {
mockDetectSourceType.mockResolvedValue("web_page")
// checkNodeExists would not be called either, so apiGet definitely won't
- render(
)
+ render(
)
const input = screen.getByPlaceholderText(/Paste URL/)
await userEvent.type(input, "https://example.com/article")
@@ -308,7 +308,7 @@ describe("AddContentModal — subscription source callout", () => {
mockDetectSourceType.mockResolvedValue("youtube_channel")
mockIsSubscriptionSource.mockReturnValue(true)
- render(
)
+ render(
)
const input = screen.getByPlaceholderText(/Paste URL/)
await userEvent.type(input, "https://youtube.com/c/testchannel")
@@ -324,7 +324,7 @@ describe("AddContentModal — subscription source callout", () => {
mockIsSubscriptionSource.mockReturnValue(false)
mockCheckNodeExists.mockResolvedValue({ exists: false, ref_id: null, status: null })
- render(
)
+ render(
)
const input = screen.getByPlaceholderText(/Paste URL/)
await userEvent.type(input, "https://youtube.com/watch?v=abc123")
@@ -354,7 +354,7 @@ describe("AddContentModal — bumpMyContentRefresh on submission", () => {
it("calls bumpMyContentRefresh after successful non-subscription submission", async () => {
mockDetectSourceType.mockResolvedValue("youtube_video")
- render(
)
+ render(
)
const input = screen.getByPlaceholderText(/Paste URL/)
await userEvent.type(input, "https://youtube.com/watch?v=newvideo")
@@ -377,7 +377,7 @@ describe("AddContentModal — bumpMyContentRefresh on submission", () => {
mockDetectSourceType.mockResolvedValue("twitter_handle")
mockIsSubscriptionSource.mockReturnValue(true)
- render(
)
+ render(
)
const input = screen.getByPlaceholderText(/Paste URL/)
await userEvent.type(input, "https://twitter.com/satoshi")
@@ -413,7 +413,7 @@ describe("AddContentModal — admin category/weight fields", () => {
mockDetectSourceType.mockResolvedValue("youtube_channel")
mockIsSubscriptionSource.mockReturnValue(true)
- render(
)
+ render(
)
const input = screen.getByPlaceholderText(/Paste URL/)
await userEvent.type(input, "https://youtube.com/@testchannel")
@@ -428,7 +428,7 @@ describe("AddContentModal — admin category/weight fields", () => {
mockDetectSourceType.mockResolvedValue("youtube_channel")
mockIsSubscriptionSource.mockReturnValue(true)
- render(
)
+ render(
)
const input = screen.getByPlaceholderText(/Paste URL/)
await userEvent.type(input, "https://youtube.com/@testchannel")
@@ -446,7 +446,7 @@ describe("AddContentModal — admin category/weight fields", () => {
mockIsSubscriptionSource.mockReturnValue(false)
mockCheckNodeExists.mockResolvedValue({ exists: false, ref_id: null, status: null })
- render(
)
+ render(
)
const input = screen.getByPlaceholderText(/Paste URL/)
await userEvent.type(input, "https://youtube.com/watch?v=abc")
diff --git a/src/lib/__tests__/add-edge-modal.test.tsx b/src/lib/__tests__/add-edge-modal.test.tsx
index 7c6c7f29..cb5eb849 100644
--- a/src/lib/__tests__/add-edge-modal.test.tsx
+++ b/src/lib/__tests__/add-edge-modal.test.tsx
@@ -22,6 +22,18 @@ vi.mock("@/lib/mock-data", () => ({
isMocksEnabled: () => false,
}))
+// Pricing / payment helpers — edge creation is a paid action.
+vi.mock("@/lib/sphinx", () => ({
+ getPrice: vi.fn().mockResolvedValue(0),
+ payL402: vi.fn().mockResolvedValue(undefined),
+}))
+
+// User store — only setBudget is read by the edge form.
+vi.mock("@/stores/user-store", () => ({
+ useUserStore: (sel: (s: Record
) => unknown) =>
+ sel({ setBudget: vi.fn() }),
+}))
+
// ---------------------------------------------------------------------------
// Fixture nodes
// ---------------------------------------------------------------------------
@@ -38,18 +50,20 @@ const FIXTURE_TARGET: GraphNode = {
}
// ---------------------------------------------------------------------------
-// Modal store — per-selector mock
+// Modal store — per-selector mock. AddEdgeForm reads sourceNode (for prefill)
+// and close. The modal shell owns open/close gating, so those aren't tested
+// here.
// ---------------------------------------------------------------------------
-let mockActiveModal: string | null = null
let mockSourceNode: GraphNode | null = null
let mockClose = vi.fn()
+const mockOpen = vi.fn()
vi.mock("@/stores/modal-store", () => ({
useModalStore: (sel: (s: Record) => unknown) =>
sel({
- activeModal: mockActiveModal,
sourceNode: mockSourceNode,
close: mockClose,
+ open: mockOpen,
}),
}))
@@ -66,27 +80,22 @@ const SCHEMA_EDGES = [
vi.mock("@/stores/schema-store", () => ({
useSchemaStore: (sel: (s: Record) => unknown) =>
- sel({ edges: SCHEMA_EDGES }),
+ sel({ edges: SCHEMA_EDGES, schemas: [] }),
}))
// ---------------------------------------------------------------------------
// Import component after mocks are set up
// ---------------------------------------------------------------------------
-import { AddEdgeModal } from "@/components/modals/add-edge-modal"
+import { AddEdgeForm } from "@/components/modals/add-edge-form"
+import { payL402 } from "@/lib/sphinx"
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
-function openModal(sourceNode: GraphNode | null = null) {
- mockActiveModal = "addEdge"
+function withSource(sourceNode: GraphNode | null = null) {
mockSourceNode = sourceNode
}
-function closeModal() {
- mockActiveModal = null
- mockSourceNode = null
-}
-
/** Type a query into a NodeSearchInput, wait for the dropdown result, and click it */
async function selectNode(placeholder: string, node: GraphNode) {
mockSearchNodes.mockResolvedValue({ nodes: [node] })
@@ -104,30 +113,18 @@ async function selectNode(placeholder: string, node: GraphNode) {
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
-describe("AddEdgeModal", () => {
+describe("AddEdgeForm", () => {
beforeEach(() => {
mockClose = vi.fn()
vi.clearAllMocks()
mockCreateEdge.mockResolvedValue({})
mockSearchNodes.mockResolvedValue({ nodes: [] })
- closeModal()
- })
-
- it("does not render when modal is closed", () => {
- closeModal()
- render()
- expect(screen.queryByText("Add Edge")).toBeNull()
+ withSource(null)
})
- it("renders when activeModal is 'addEdge'", () => {
- openModal()
- render()
- expect(screen.getByRole("heading", { name: "Add Edge" })).toBeDefined()
- })
-
- it("renders with empty source and target NodeSearchInput fields when opened from toolbar", () => {
- openModal(null)
- render()
+ it("renders empty source and target NodeSearchInput fields when opened from toolbar", () => {
+ withSource(null)
+ render()
const sourceInput = screen.getByPlaceholderText("Search source node…") as HTMLInputElement
const targetInput = screen.getByPlaceholderText("Search target node…") as HTMLInputElement
expect(sourceInput.value).toBe("")
@@ -135,8 +132,8 @@ describe("AddEdgeModal", () => {
})
it("pre-fills source field with node display name when sourceNode is set in the modal store", () => {
- openModal(FIXTURE_SOURCE)
- render()
+ withSource(FIXTURE_SOURCE)
+ render()
// Selected state renders the node title, not a raw ref_id
expect(screen.getByText("Source Topic")).toBeDefined()
// Target should still be an empty search input
@@ -144,8 +141,8 @@ describe("AddEdgeModal", () => {
})
it("target field is always empty on open even when sourceNode is set", () => {
- openModal(FIXTURE_SOURCE)
- render()
+ withSource(FIXTURE_SOURCE)
+ render()
const targetInput = screen.getByPlaceholderText("Search target node…") as HTMLInputElement
expect(targetInput.value).toBe("")
})
@@ -155,16 +152,16 @@ describe("AddEdgeModal", () => {
// -------------------------------------------------------------------------
describe("Edge type dropdown", () => {
it("excludes CHILD_OF from options", async () => {
- openModal()
- render()
+ withSource()
+ render()
const trigger = screen.getByText("Choose an edge type...").closest("button") as HTMLButtonElement
await userEvent.click(trigger)
expect(screen.queryByText("CHILD_OF")).toBeNull()
})
it("shows all unique edge types from schema store (deduped, no CHILD_OF)", async () => {
- openModal()
- render()
+ withSource()
+ render()
const trigger = screen.getByText("Choose an edge type...").closest("button") as HTMLButtonElement
await userEvent.click(trigger)
expect(screen.getAllByText("HAS_TOPIC").length).toBeGreaterThan(0)
@@ -178,8 +175,8 @@ describe("AddEdgeModal", () => {
// -------------------------------------------------------------------------
describe("Validation", () => {
it("shows an error when source is not selected on submit", async () => {
- openModal(null)
- render()
+ withSource(null)
+ render()
// Select target only
await selectNode("Search target node…", FIXTURE_TARGET)
// Select edge type
@@ -193,8 +190,8 @@ describe("AddEdgeModal", () => {
})
it("shows an error when target is not selected on submit", async () => {
- openModal(FIXTURE_SOURCE)
- render()
+ withSource(FIXTURE_SOURCE)
+ render()
// Select edge type
const trigger = screen.getByText("Choose an edge type...").closest("button") as HTMLButtonElement
await userEvent.click(trigger)
@@ -205,8 +202,8 @@ describe("AddEdgeModal", () => {
})
it("shows an error when edge type is not selected on submit", async () => {
- openModal(null)
- render()
+ withSource(null)
+ render()
await selectNode("Search source node…", FIXTURE_SOURCE)
await selectNode("Search target node…", FIXTURE_TARGET)
await userEvent.click(screen.getByRole("button", { name: /add edge/i }))
@@ -220,8 +217,8 @@ describe("AddEdgeModal", () => {
// -------------------------------------------------------------------------
describe("Submission", () => {
it("calls createEdge with ref_id values from selected nodes on valid submit", async () => {
- openModal(null)
- render()
+ 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
@@ -238,8 +235,8 @@ describe("AddEdgeModal", () => {
})
it("calls createEdge with pre-filled source node ref_id and selected target", async () => {
- openModal(FIXTURE_SOURCE)
- render()
+ withSource(FIXTURE_SOURCE)
+ render()
await selectNode("Search target node…", FIXTURE_TARGET)
const trigger = screen.getByText("Choose an edge type...").closest("button") as HTMLButtonElement
await userEvent.click(trigger)
@@ -255,8 +252,8 @@ describe("AddEdgeModal", () => {
})
it("shows success state after createEdge resolves", async () => {
- openModal(null)
- render()
+ 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
@@ -269,8 +266,8 @@ describe("AddEdgeModal", () => {
})
it("calls close after success auto-close timeout", async () => {
- openModal(null)
- render()
+ 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
@@ -281,66 +278,56 @@ describe("AddEdgeModal", () => {
await waitFor(() => expect(mockClose).toHaveBeenCalled(), { timeout: 2500 })
})
- it("shows inline error and keeps modal open when createEdge rejects", async () => {
- mockCreateEdge.mockRejectedValueOnce(new Error("Duplicate edge"))
- openModal(null)
- render()
+ it("on 402, settles the L402 invoice and retries, then succeeds", async () => {
+ mockCreateEdge.mockRejectedValueOnce(new Response(null, { status: 402 }))
+ mockCreateEdge.mockResolvedValueOnce({})
+ 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(screen.getByText("Duplicate edge")).toBeDefined()
- })
- // Modal stays open — title still visible
- expect(screen.getByRole("heading", { name: "Add Edge" })).toBeDefined()
- expect(mockClose).not.toHaveBeenCalled()
- })
- })
- // -------------------------------------------------------------------------
- // Close / reset
- // -------------------------------------------------------------------------
- describe("Close behaviour", () => {
- it("calls close() when the dialog is dismissed via onOpenChange", async () => {
- openModal()
- const { rerender } = render()
- // Simulate dialog close by changing activeModal
- mockActiveModal = null
- rerender()
- expect(screen.queryByText("Add Edge")).toBeNull()
+ await waitFor(() => expect(payL402).toHaveBeenCalled())
+ await waitFor(() => expect(mockCreateEdge).toHaveBeenCalledTimes(2))
+ await waitFor(() => expect(screen.getByText("Edge created!")).toBeDefined())
+ expect(mockOpen).not.toHaveBeenCalled()
})
- it("resets all field values and clears errors after close and reopen", async () => {
- mockCreateEdge.mockRejectedValueOnce(new Error("bad"))
- openModal(null)
- const { rerender } = render()
+ it("opens the budget bar when payment fails on a 402", async () => {
+ mockCreateEdge.mockRejectedValue(new Response(null, { status: 402 }))
+ vi.mocked(payL402).mockRejectedValueOnce(new Error("not enough sats"))
+ 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(mockOpen).toHaveBeenCalledWith("budget"))
+ expect(mockClose).not.toHaveBeenCalled()
+ })
- // Fill and submit to trigger error
+ it("shows inline error and keeps the form mounted when createEdge rejects", async () => {
+ mockCreateEdge.mockRejectedValueOnce(new Error("Duplicate edge"))
+ 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(screen.getByText("bad")).toBeDefined())
-
- // Close modal
- mockActiveModal = null
- mockSourceNode = null
- rerender()
-
- // Reopen fresh
- mockActiveModal = "addEdge"
- mockSourceNode = null
- rerender()
-
- // Fields should be reset — empty search inputs visible
- expect(screen.getByPlaceholderText("Search source node…")).toBeDefined()
- expect(screen.getByPlaceholderText("Search target node…")).toBeDefined()
- expect(screen.queryByText("bad")).toBeNull()
+ await waitFor(() => {
+ expect(screen.getByText("Duplicate edge")).toBeDefined()
+ })
+ // Form stays mounted — submit button still present, close not called
+ expect(screen.getByRole("button", { name: /add edge/i })).toBeDefined()
+ expect(mockClose).not.toHaveBeenCalled()
})
})
})
diff --git a/src/lib/__tests__/node-schema-utils.test.ts b/src/lib/__tests__/node-schema-utils.test.ts
new file mode 100644
index 00000000..e6660b21
--- /dev/null
+++ b/src/lib/__tests__/node-schema-utils.test.ts
@@ -0,0 +1,106 @@
+import { describe, it, expect } from "vitest"
+import {
+ humanizeFieldKey,
+ fieldTypeHint,
+ categorizeField,
+ fieldsForSchema,
+ OPTIONAL_GROUP_ORDER,
+} from "@/lib/node-schema-utils"
+import type { SchemaNode } from "@/app/ontology/page"
+
+describe("humanizeFieldKey", () => {
+ it("title-cases snake_case keys", () => {
+ expect(humanizeFieldKey("source_link")).toBe("Source link")
+ expect(humanizeFieldKey("english_translation")).toBe("English translation")
+ })
+ it("splits camelCase", () => {
+ expect(humanizeFieldKey("dateAddedToGraph")).toBe("Date added to graph")
+ })
+ it("handles single words and empties", () => {
+ expect(humanizeFieldKey("name")).toBe("Name")
+ expect(humanizeFieldKey("")).toBe("")
+ })
+})
+
+describe("fieldTypeHint", () => {
+ it("normalizes backend types to short hints", () => {
+ expect(fieldTypeHint("integer")).toBe("int")
+ expect(fieldTypeHint("number")).toBe("float")
+ expect(fieldTypeHint("date")).toBe("datetime")
+ expect(fieldTypeHint("boolean")).toBe("bool")
+ expect(fieldTypeHint("string")).toBe("string")
+ expect(fieldTypeHint("")).toBe("string")
+ })
+})
+
+describe("categorizeField", () => {
+ // Mirrors the design's groupings for the example Clip / Claim schemas.
+ it("buckets scoring attributes into signal", () => {
+ expect(categorizeField("sentiment_score", "float")).toBe("signal")
+ expect(categorizeField("boost", "int")).toBe("signal")
+ expect(categorizeField("num_boost", "int")).toBe("signal")
+ expect(categorizeField("confidence", "float")).toBe("signal")
+ expect(categorizeField("reliability", "float")).toBe("signal")
+ expect(categorizeField("influence", "float")).toBe("signal")
+ })
+ it("buckets provenance/bookkeeping attributes into meta", () => {
+ expect(categorizeField("language", "string")).toBe("meta")
+ expect(categorizeField("date", "datetime")).toBe("meta")
+ expect(categorizeField("pub_key", "string")).toBe("meta")
+ expect(categorizeField("duration", "string")).toBe("meta")
+ expect(categorizeField("followers", "int")).toBe("meta")
+ expect(categorizeField("keywords", "string")).toBe("meta")
+ })
+ it("buckets remaining string attributes into content", () => {
+ expect(categorizeField("english_translation", "string")).toBe("content")
+ expect(categorizeField("link", "string")).toBe("content")
+ expect(categorizeField("thumbnail", "string")).toBe("content")
+ expect(categorizeField("description", "string")).toBe("content")
+ expect(categorizeField("stance", "string")).toBe("content")
+ })
+ it("only ever returns a known group", () => {
+ for (const key of ["x", "weird_attr", "foo123"]) {
+ expect(OPTIONAL_GROUP_ORDER).toContain(categorizeField(key))
+ }
+ })
+})
+
+describe("fieldsForSchema — hidden attributes", () => {
+ const schema = {
+ type: "Clip",
+ attributes: [
+ { key: "text", type: "string", required: true },
+ { key: "language", type: "string", required: false },
+ { key: "project_id", type: "string", required: false },
+ { key: "pub_key", type: "string", required: false },
+ { key: "pubkey", type: "string", required: false },
+ { key: "boost", type: "int", required: false },
+ { key: "num_boost", type: "int", required: false },
+ { key: "sentiment_score", type: "float", required: false },
+ { key: "weight", type: "float", required: false },
+ ],
+ } as unknown as SchemaNode
+
+ const keys = fieldsForSchema(schema).map((f) => f.key)
+
+ it("keeps user-facing content/metadata fields", () => {
+ expect(keys).toContain("text")
+ expect(keys).toContain("language")
+ })
+
+ it("hides project_id and pub_key/pubkey provenance fields", () => {
+ expect(keys).not.toContain("project_id")
+ expect(keys).not.toContain("pub_key")
+ expect(keys).not.toContain("pubkey")
+ })
+
+ it("hides the entire Signals & scoring group", () => {
+ expect(keys).not.toContain("boost")
+ expect(keys).not.toContain("num_boost")
+ expect(keys).not.toContain("sentiment_score")
+ })
+
+ it("still hides pre-existing system attributes", () => {
+ expect(keys).not.toContain("weight")
+ })
+})
diff --git a/src/lib/__tests__/settings-modal-removed.test.ts b/src/lib/__tests__/settings-modal-removed.test.ts
index 5d5a1848..1939f5cb 100644
--- a/src/lib/__tests__/settings-modal-removed.test.ts
+++ b/src/lib/__tests__/settings-modal-removed.test.ts
@@ -17,7 +17,7 @@ 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 = ["addContent", "budget", "addNode", "editNode", "addEdge"] as const
+ const validIds = ["add", "budget", "editNode"] as const
for (const id of validIds) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -33,7 +33,7 @@ describe("modal-store – settings removed", () => {
it("close() resets activeModal to null", () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- useModalStore.getState().open("addContent" as any)
+ useModalStore.getState().open("add" as any)
useModalStore.getState().close()
expect(useModalStore.getState().activeModal).toBeNull()
})
diff --git a/src/lib/__tests__/toolkit-fab.test.tsx b/src/lib/__tests__/toolkit-fab.test.tsx
index 746d7550..c67624c9 100644
--- a/src/lib/__tests__/toolkit-fab.test.tsx
+++ b/src/lib/__tests__/toolkit-fab.test.tsx
@@ -18,9 +18,10 @@ vi.mock("@/stores/user-store", () => ({
}))
const modalOpen = vi.fn()
+const modalOpenAdd = vi.fn()
vi.mock("@/stores/modal-store", () => ({
useModalStore: (sel?: (s: unknown) => unknown) => {
- const state = { open: modalOpen }
+ const state = { open: modalOpen, openAdd: modalOpenAdd }
return sel ? sel(state) : state
},
}))
@@ -93,11 +94,10 @@ describe("ToolkitFAB", () => {
const fab = screen.getByRole("button", { name: "Open menu" })
fireEvent.click(fab)
- expect(screen.getByText("Add Content")).toBeInTheDocument()
+ expect(screen.getByText("Add to graph")).toBeInTheDocument()
expect(screen.getByText("My Content")).toBeInTheDocument()
expect(screen.getByText("Sources")).toBeInTheDocument()
expect(screen.getByText("Following")).toBeInTheDocument()
- expect(screen.getByText("Add Node")).toBeInTheDocument()
})
it("clicking FAB again (Close menu) closes the popup", async () => {
@@ -105,10 +105,10 @@ describe("ToolkitFAB", () => {
render()
fireEvent.click(screen.getByRole("button", { name: "Open menu" }))
- expect(screen.getByText("Add Content")).toBeInTheDocument()
+ expect(screen.getByText("Add to graph")).toBeInTheDocument()
fireEvent.click(screen.getByRole("button", { name: "Close menu" }))
- expect(screen.queryByText("Add Content")).not.toBeInTheDocument()
+ expect(screen.queryByText("Add to graph")).not.toBeInTheDocument()
})
it("clicking backdrop closes the popup", async () => {
@@ -116,13 +116,13 @@ describe("ToolkitFAB", () => {
const { container } = render()
fireEvent.click(screen.getByRole("button", { name: "Open menu" }))
- expect(screen.getByText("Add Content")).toBeInTheDocument()
+ expect(screen.getByText("Add to graph")).toBeInTheDocument()
// The backdrop is a fixed inset-0 div rendered before the wrapper
const backdrop = container.querySelector(".fixed.inset-0.z-40")
expect(backdrop).not.toBeNull()
fireEvent.click(backdrop!)
- expect(screen.queryByText("Add Content")).not.toBeInTheDocument()
+ expect(screen.queryByText("Add to graph")).not.toBeInTheDocument()
})
it("clicking an action button triggers the action and closes popup", async () => {
@@ -130,10 +130,10 @@ describe("ToolkitFAB", () => {
render()
fireEvent.click(screen.getByRole("button", { name: "Open menu" }))
- fireEvent.click(screen.getByText("Add Content"))
+ fireEvent.click(screen.getByText("Add to graph"))
- expect(modalOpen).toHaveBeenCalledWith("addContent")
- expect(screen.queryByText("Add Content")).not.toBeInTheDocument()
+ expect(modalOpenAdd).toHaveBeenCalledWith("source")
+ expect(screen.queryByText("Add to graph")).not.toBeInTheDocument()
})
it("Sources button calls onToggleSources and closes popup", async () => {
diff --git a/src/lib/node-schema-utils.ts b/src/lib/node-schema-utils.ts
index 4effedf5..fa8d46f1 100644
--- a/src/lib/node-schema-utils.ts
+++ b/src/lib/node-schema-utils.ts
@@ -4,29 +4,109 @@ import type { SchemaNode, SchemaAttribute } from "@/app/ontology/page"
// owner_reference_id is set from the LSAT, weight/is_muted are
// graph-internal moderation knobs, unique_source_id is for dedup of
// ingested content (Stakwork). date_added_to_graph is auto-set.
+// project_id is the graph association and pub_key/pubkey are provenance —
+// both set by the backend, not typed by hand.
export const SYSTEM_ATTRIBUTES = new Set([
"weight",
"is_muted",
"unique_source_id",
"owner_reference_id",
"date_added_to_graph",
+ "project_id",
+ "pub_key",
+ "pubkey",
])
+// A field is hidden from every node form when it's a system/book-keeping
+// attribute OR a computed "Signals & scoring" value (boost, sentiment_score,
+// confidence, reliability, …). None of these are hand-entered — they're
+// backend-managed, so we never render or submit them.
+function isHiddenAttribute(a: SchemaAttribute): boolean {
+ return SYSTEM_ATTRIBUTES.has(a.key) || categorizeField(a.key, a.type) === "signal"
+}
+
// Merge own + inherited attributes into one form-field list, with own
-// attributes first. Duplicate keys are deduped (own wins). System-level
-// inherited attrs are filtered out — they're backend-managed, not user input.
+// attributes first. Duplicate keys are deduped (own wins). System-level and
+// computed-signal attrs are filtered out — they're backend-managed, not input.
export function fieldsForSchema(schema: SchemaNode): SchemaAttribute[] {
const seen = new Set()
const out: SchemaAttribute[] = []
for (const a of schema.attributes) {
- if (seen.has(a.key) || SYSTEM_ATTRIBUTES.has(a.key)) continue
+ if (seen.has(a.key) || isHiddenAttribute(a)) continue
seen.add(a.key)
out.push(a)
}
for (const a of schema.inherited_attributes ?? []) {
- if (seen.has(a.key) || SYSTEM_ATTRIBUTES.has(a.key)) continue
+ if (seen.has(a.key) || isHiddenAttribute(a)) continue
seen.add(a.key)
out.push(a)
}
return out
}
+
+// Optional attributes are bucketed into these groups so the node form can
+// collapse them into labelled sections (Content / Metadata / Signals) instead
+// of a flat dump. Required ("core") fields are rendered separately, up front.
+export type FieldGroup = "content" | "meta" | "signal"
+
+export const OPTIONAL_GROUP_ORDER: FieldGroup[] = ["content", "meta", "signal"]
+
+export const OPTIONAL_GROUP_LABELS: Record = {
+ content: "Content",
+ meta: "Metadata",
+ signal: "Signals & scoring",
+}
+
+// Mono type hint shown on the right of each field label.
+export function fieldTypeHint(type: string): string {
+ const t = type.toLowerCase()
+ if (t === "integer") return "int"
+ if (t === "number") return "float"
+ if (t === "datetime" || t === "date") return "datetime"
+ if (t === "boolean") return "bool"
+ return t || "string"
+}
+
+// Scoring / signal attributes — numeric weights and model outputs.
+const SIGNAL_TOKENS = [
+ "boost", "sentiment", "confidence", "reliab", "influence",
+ "significan", "prevalence", "score", "rating", "weight", "rank",
+]
+// Bookkeeping / provenance attributes.
+const META_TOKENS = [
+ "language", "lang", "country", "region", "date", "time", "year",
+ "created", "updated", "first_seen", "duration", "follower", "likes",
+ "repost", "alias", "keyword", "pub_key", "pubkey", "count", "_id",
+]
+
+// Bucket an optional attribute into a display group from its key (and type as a
+// tiebreaker). Heuristic — the backend schema has no group metadata, so we
+// infer it; anything unclassified falls into Content (the default catch-all).
+export function categorizeField(key: string, type?: string): FieldGroup {
+ const k = key.toLowerCase()
+ if (SIGNAL_TOKENS.some((t) => k.includes(t))) return "signal"
+ if (META_TOKENS.some((t) => k.includes(t))) return "meta"
+ // Untagged numeric values read as signals more often than content.
+ const t = (type ?? "").toLowerCase()
+ if (t === "float" || t === "number") return "signal"
+ return "content"
+}
+
+// Turn a raw attribute key into a human-readable label for form display.
+// Splits on underscores and camelCase boundaries, then Title-cases:
+// "source_link" → "Source link"
+// "dateAddedToGraph" → "Date added to graph"
+// The raw key is still shown alongside as a muted hint, so this only has to
+// be readable, not reversible.
+export function humanizeFieldKey(key: string): string {
+ const words = key
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
+ .split(/[_\s]+/)
+ .filter(Boolean)
+ if (words.length === 0) return key
+ const [first, ...rest] = words
+ return [
+ first.charAt(0).toUpperCase() + first.slice(1).toLowerCase(),
+ ...rest.map((w) => w.toLowerCase()),
+ ].join(" ")
+}
diff --git a/src/lib/unlock-node.ts b/src/lib/unlock-node.ts
index 0496a05b..88726c86 100644
--- a/src/lib/unlock-node.ts
+++ b/src/lib/unlock-node.ts
@@ -3,7 +3,7 @@ import { useGraphStore } from "@/stores/graph-store"
import { usePlayerStore } from "@/stores/player-store"
/**
- * Shared unlock helper used by both AddContentModal (cache-hit branch) and
+ * Shared unlock helper used by both AddSourceForm (cache-hit branch) and
* NodePreviewPanel's unlock button.
*
* - Fetches GET /v2/nodes/:ref_id?expand=edges (L402 auto-attached via api.get)
diff --git a/src/stores/modal-store.ts b/src/stores/modal-store.ts
index 49f80bb5..07fbf17d 100644
--- a/src/stores/modal-store.ts
+++ b/src/stores/modal-store.ts
@@ -3,13 +3,19 @@
import { create } from "zustand"
import type { GraphNode } from "@/lib/graph-api"
-type ModalId = "addContent" | "budget" | "addNode" | "editNode" | "addEdge" | null
+type ModalId = "add" | "budget" | "editNode" | null
+export type AddTab = "source" | "node" | "edge"
interface ModalState {
activeModal: ModalId
+ // 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
editingNode: GraphNode | null
sourceNode: GraphNode | null
open: (id: ModalId) => void
+ openAdd: (tab?: AddTab) => void
+ setAddTab: (tab: AddTab) => void
openEdit: (node: GraphNode) => void
openAddEdge: (sourceNode?: GraphNode) => void
close: () => void
@@ -17,10 +23,15 @@ interface ModalState {
export const useModalStore = create((set) => ({
activeModal: null,
+ addTab: "source",
editingNode: null,
sourceNode: null,
open: (activeModal) => set({ activeModal }),
+ 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: "addEdge", sourceNode: sourceNode ?? null }),
- close: () => set({ activeModal: null, editingNode: null, sourceNode: null }),
+ openAddEdge: (sourceNode?: GraphNode) =>
+ set({ activeModal: "add", addTab: "edge", sourceNode: sourceNode ?? null }),
+ close: () =>
+ set({ activeModal: null, addTab: "source", editingNode: null, sourceNode: null }),
}))