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
100 changes: 58 additions & 42 deletions src/components/layout/connections-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ type GroupBy = "edge_type" | "node_type"
export function ConnectionsSection({ nodeRefId, schemas, currentNode, onNavigate }: ConnectionsSectionProps) {
const [groupBy, setGroupBy] = useState<GroupBy>("edge_type")
const [confirmingDelete, setConfirmingDelete] = useState<string | null>(null)
const [deleteError, setDeleteError] = useState<{ edgeRefId: string; message: string } | null>(null)
const nodes = useGraphStore((s) => s.nodes)
const edges = useGraphStore((s) => s.edges)
const removeEdge = useGraphStore((s) => s.removeEdge)
Expand Down Expand Up @@ -72,11 +73,13 @@ export function ConnectionsSection({ nodeRefId, schemas, currentNode, onNavigate
if (!isAdmin) return
try {
await deleteEdge(edgeRefId)
removeEdge(edgeRefId)
setConfirmingDelete(null)
setDeleteError(null)
} catch {
// Best-effort — remove locally regardless
setConfirmingDelete(null)
setDeleteError({ edgeRefId, message: "Could not remove connection. Please try again." })
}
removeEdge(edgeRefId)
setConfirmingDelete(null)
}

return (
Expand Down Expand Up @@ -134,51 +137,64 @@ export function ConnectionsSection({ nodeRefId, schemas, currentNode, onNavigate
</p>
{conns.map((conn, i) => {
const isConfirming = confirmingDelete === conn.edge_ref_id
const rowError =
conn.edge_ref_id !== undefined && deleteError?.edgeRefId === conn.edge_ref_id
? deleteError.message
: null
return (
<div
key={`${conn.peer.ref_id}-${i}`}
className="w-full flex items-center gap-2 rounded-md px-2 py-1.5 bg-muted/20 border border-border/20 hover:bg-muted/40 transition-colors"
>
<button
className="flex-1 flex items-center justify-between gap-2 cursor-pointer text-left min-w-0"
onClick={() => onNavigate?.(conn.peer)}
<div key={`${conn.peer.ref_id}-${i}`} className="space-y-0.5">
<div
className="w-full flex items-center gap-2 rounded-md px-2 py-1.5 bg-muted/20 border border-border/20 hover:bg-muted/40 transition-colors"
>
<span className="text-xs truncate min-w-0">{resolveTitle(conn.peer)}</span>
<Badge
variant="outline"
className="text-[9px] px-1.5 py-0 h-4 border-border/50 text-muted-foreground font-mono shrink-0"
<button
className="flex-1 flex items-center justify-between gap-2 cursor-pointer text-left min-w-0"
onClick={() => onNavigate?.(conn.peer)}
>
{displayNodeType(conn.peer.node_type)}
</Badge>
</button>
{isAdmin && conn.edge_ref_id !== undefined && (
isConfirming ? (
<div className="flex items-center gap-1 shrink-0">
<span className="text-[9px] text-muted-foreground">Remove?</span>
<button
onClick={() => handleConfirmDelete(conn.edge_ref_id!)}
className="text-[9px] font-mono text-destructive hover:text-destructive/80 transition-colors"
aria-label="Confirm remove"
>
Yes
</button>
<span className="text-xs truncate min-w-0">{resolveTitle(conn.peer)}</span>
<Badge
variant="outline"
className="text-[9px] px-1.5 py-0 h-4 border-border/50 text-muted-foreground font-mono shrink-0"
>
{displayNodeType(conn.peer.node_type)}
</Badge>
</button>
{isAdmin && conn.edge_ref_id !== undefined && (
isConfirming ? (
<div className="flex items-center gap-1 shrink-0">
<span className="text-[9px] text-muted-foreground">Remove?</span>
<button
onClick={() => handleConfirmDelete(conn.edge_ref_id!)}
className="text-[9px] font-mono text-destructive hover:text-destructive/80 transition-colors"
aria-label="Confirm remove"
>
Yes
</button>
<button
onClick={() => setConfirmingDelete(null)}
className="text-[9px] font-mono text-muted-foreground hover:text-foreground transition-colors"
aria-label="Cancel remove"
>
No
</button>
</div>
) : (
<button
onClick={() => setConfirmingDelete(null)}
className="text-[9px] font-mono text-muted-foreground hover:text-foreground transition-colors"
aria-label="Cancel remove"
onClick={() => {
setConfirmingDelete(conn.edge_ref_id!)
setDeleteError(null)
}}
className="shrink-0 text-muted-foreground hover:text-destructive transition-colors"
aria-label="Remove connection"
>
No
<Trash2 className="h-3 w-3" />
</button>
</div>
) : (
<button
onClick={() => setConfirmingDelete(conn.edge_ref_id!)}
className="shrink-0 text-muted-foreground hover:text-destructive transition-colors"
aria-label="Remove connection"
>
<Trash2 className="h-3 w-3" />
</button>
)
)
)}
</div>
{rowError && (
<p role="alert" className="px-2 text-[9px] text-destructive">
{rowError}
</p>
)}
</div>
)
Expand Down
26 changes: 26 additions & 0 deletions src/lib/__tests__/connections-section.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,32 @@ describe("ConnectionsSection – admin delete", () => {
expect(screen.queryByText("Remove?")).toBeNull()
})

it("keeps the edge and shows an inline error when deleteEdge fails", async () => {
mockIsAdmin = true
mockDeleteEdge.mockRejectedValueOnce(new Error("500"))
render(<ConnectionsSection nodeRefId="n1" schemas={[]} />)
fireEvent.click(screen.getByRole("button", { name: "Remove connection" }))
fireEvent.click(screen.getByRole("button", { name: "Confirm remove" }))

expect(await screen.findByRole("alert")).toHaveTextContent("Could not remove connection")
expect(mockDeleteEdge).toHaveBeenCalledWith("edge-ref-1")
expect(mockRemoveEdge).not.toHaveBeenCalled()
expect(screen.getByText("Blockchain")).toBeInTheDocument()
expect(screen.queryByText("Remove?")).toBeNull()
})

it("clears the error when a remove is retried", async () => {
mockIsAdmin = true
mockDeleteEdge.mockRejectedValueOnce(new Error("500"))
render(<ConnectionsSection nodeRefId="n1" schemas={[]} />)
fireEvent.click(screen.getByRole("button", { name: "Remove connection" }))
fireEvent.click(screen.getByRole("button", { name: "Confirm remove" }))
await screen.findByRole("alert")

fireEvent.click(screen.getByRole("button", { name: "Remove connection" }))
expect(screen.queryByRole("alert")).toBeNull()
})

it("hides trash for edges without ref_id even when isAdmin=true", () => {
mockIsAdmin = true
mockEdges = [EDGE_MENTIONS_N2] // no ref_id
Expand Down
3 changes: 3 additions & 0 deletions src/lib/__tests__/node-remap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,9 @@ describe("fieldsForSchema (from node-schema-utils)", () => {
expect(SYSTEM_ATTRIBUTES.has("is_muted")).toBe(true)
expect(SYSTEM_ATTRIBUTES.has("unique_source_id")).toBe(true)
expect(SYSTEM_ATTRIBUTES.has("owner_reference_id")).toBe(true)
expect(SYSTEM_ATTRIBUTES.has("deleted_at")).toBe(true)
expect(SYSTEM_ATTRIBUTES.has("deleted_at_backfilled")).toBe(true)
expect(SYSTEM_ATTRIBUTES.has("is_deleted")).toBe(true)
expect(SYSTEM_ATTRIBUTES.has("date_added_to_graph")).toBe(true)
})

Expand Down
6 changes: 5 additions & 1 deletion src/lib/node-schema-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import type { SchemaNode, SchemaAttribute } from "@/lib/schema-types"
// 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.
// both set by the backend, not typed by hand. deleted_at, deleted_at_backfilled
// and is_deleted are the soft-delete markers, reserved for the backend.
export const SYSTEM_ATTRIBUTES = new Set([
"weight",
"is_muted",
Expand All @@ -15,6 +16,9 @@ export const SYSTEM_ATTRIBUTES = new Set([
"project_id",
"pub_key",
"pubkey",
"deleted_at",
"deleted_at_backfilled",
"is_deleted",
])

// A field is hidden from every node form when it's a system/book-keeping
Expand Down
Loading