- Edge Type
+ Relationship Type
@@ -59,6 +128,15 @@ export function EdgeTypePanel({ edgeType, edges, allSchemas, onClose }: Props) {
{/* Body */}
+ {!canEdit && (
+
+
+
+ Read-only — admin access required to edit.
+
+
+ )}
+
{/* Connections section */}
@@ -69,10 +147,8 @@ export function EdgeTypePanel({ edgeType, edges, allSchemas, onClose }: Props) {
) : (
{edges.map((e) => {
- const sourceLabel =
- e.source_type ?? refIdToType[e.source]?.type ?? e.source
- const targetLabel =
- e.target_type ?? refIdToType[e.target]?.type ?? e.target
+ const sourceLabel = e.source_type ?? refIdToType[e.source]?.type ?? e.source
+ const targetLabel = e.target_type ?? refIdToType[e.target]?.type ?? e.target
return (
{targetLabel}
+ {canEdit && (
+ onDeleteConnection(e.ref_id)}
+ title="Remove connection"
+ className="ml-auto text-muted-foreground/40 hover:text-destructive transition-colors shrink-0"
+ >
+
+
+ )}
)
})}
)}
+
+ {/* Add connection */}
+ {canEdit && (
+
+ )}
- {/* Attributes section — only render if any edge has attributes */}
- {hasAttributes && (
- <>
-
+
+
+ {/* Attributes section */}
+
+
+
+ Attributes
+
+ {canEdit && (
+
+
+ Add
+
+ )}
+
+
+ {attributes.length === 0 ? (
+
No attributes
+ ) : (
-
- Attributes
-
-
- {attrEntries.map(([key, { type, optional }]) => (
+ {attributes.map((attr, i) =>
+ canEdit ? (
+
+
+
+ updateAttr(i, { key: e.target.value })}
+ placeholder="key"
+ maxLength={MAX_LENGTHS.SCHEMA_ATTRIBUTE_KEY}
+ className="h-6 flex-1 min-w-0 bg-transparent text-xs text-foreground placeholder:text-muted-foreground/50 focus:outline-none"
+ />
+ updateAttr(i, { type: val })}
+ options={ATTR_TYPES.map((t) => ({ value: t, label: t }))}
+ compact
+ className="w-[80px] shrink-0"
+ />
+
+
+ updateAttr(i, { required: !!checked })}
+ className="scale-75"
+ />
+
+ {attr.required ? "Required" : "Optional"}
+
+
+
+
removeAttr(i)}
+ className="text-muted-foreground/40 hover:text-destructive transition-colors shrink-0"
+ >
+
+
+
+ ) : (
-
{key}
+
{attr.key}
- {type}
+ {attr.type}
- {optional ? "Optional" : "Required"}
+ {attr.required ? "Required" : "Optional"}
- ))}
-
+ )
+ )}
- >
- )}
+ )}
+
+ {canEdit && dirty && (
+
+
+ Applies to all {edges.length} connection{edges.length !== 1 ? "s" : ""}
+
+ onSaveAttributes(attributes.filter((a) => a.key.trim()))}
+ className="h-7 text-xs"
+ >
+ Save attributes
+
+
+ )}
+
+
+ {/* Footer */}
+ {canEdit && (
+
+ {error &&
{error}
}
+ {confirmDelete ? (
+
+
Delete this type?
+
+ setConfirmDelete(false)}
+ className="h-7 text-xs"
+ >
+ Cancel
+
+
+ Delete
+
+
+
+ ) : (
+
setConfirmDelete(true)}
+ className="w-full text-xs text-muted-foreground hover:text-destructive"
+ >
+
+ Delete relationship type
+
+ )}
+
+ )}
)
}
diff --git a/src/app/admin/ontology/ontology-graph.tsx b/src/app/admin/ontology/ontology-graph.tsx
index 4a9ea1c..af5ac75 100644
--- a/src/app/admin/ontology/ontology-graph.tsx
+++ b/src/app/admin/ontology/ontology-graph.tsx
@@ -1,6 +1,6 @@
"use client"
-import { useEffect, useMemo, useRef } from "react"
+import { useEffect, useMemo, useRef, useState } from "react"
import dagre from "dagre"
import { zoom as d3Zoom, zoomIdentity, ZoomBehavior, ZoomTransform } from "d3-zoom"
import { select as d3Select } from "d3-selection"
@@ -8,16 +8,36 @@ import "d3-transition"
import { ZoomIn, ZoomOut, Maximize2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import type { SchemaNode, SchemaEdge } from "@/lib/schema-types"
+import { getSchemaIcon } from "@/lib/schema-icons"
const NODE_WIDTH = 160
const NODE_HEIGHT = 56
const PADDING = 60
+const DIM_OPACITY = 0.22
+
+/** Point on a node card's border along the line from its center toward (towardX, towardY). */
+function rectBorderPoint(cx: number, cy: number, towardX: number, towardY: number) {
+ const dx = towardX - cx
+ const dy = towardY - cy
+ if (dx === 0 && dy === 0) return { x: cx, y: cy }
+ const hw = NODE_WIDTH / 2
+ const hh = NODE_HEIGHT / 2
+ const scale = 1 / Math.max(Math.abs(dx) / hw, Math.abs(dy) / hh)
+ return { x: cx + dx * scale, y: cy + dy * scale }
+}
+
+/** Truncate a label to fit a node card, appending an ellipsis when cut. */
+function truncate(label: string, max: number): string {
+ return label.length > max ? `${label.slice(0, max - 1)}…` : label
+}
interface Props {
schemas: SchemaNode[]
edges: SchemaEdge[]
selectedId: string | null
onSelect: (id: string) => void
+ /** Clear the selection (Esc / background click) to return to the full view. */
+ onClear?: () => void
selectedEdgeType?: string | null
}
@@ -65,35 +85,75 @@ function buildLayout(schemas: SchemaNode[], edges: SchemaEdge[]) {
return g
}
-function edgePath(
+/**
+ * Layout for the "selected view": the selected node plus its immediate
+ * neighbors only (parent + children via hierarchy, and anything directly
+ * related), laid out compactly so the relevant slice is close together.
+ */
+function buildFocusedLayout(
+ schemas: SchemaNode[],
+ edges: SchemaEdge[],
+ selectedId: string
+) {
+ const selected = schemas.find((s) => s.ref_id === selectedId)
+ if (!selected) return buildLayout(schemas, edges)
+
+ const members = new Set
([selectedId])
+ let hasChildOf = false
+ for (const e of edges) {
+ if (e.edge_type === "CHILD_OF") {
+ hasChildOf = true
+ if (e.target === selectedId) members.add(e.source) // a child of selected
+ if (e.source === selectedId) members.add(e.target) // the parent of selected
+ } else if (e.source === selectedId) {
+ members.add(e.target)
+ } else if (e.target === selectedId) {
+ members.add(e.source)
+ }
+ }
+ // Fallback to the parent field when there are no CHILD_OF edges.
+ if (!hasChildOf) {
+ if (selected.parent) {
+ const p = schemas.find((s) => s.type === selected.parent)
+ if (p) members.add(p.ref_id)
+ }
+ for (const s of schemas) {
+ if (s.parent && s.parent === selected.type) members.add(s.ref_id)
+ }
+ }
+
+ const memberSchemas = schemas.filter((s) => members.has(s.ref_id))
+ const memberEdges = edges.filter((e) => members.has(e.source) && members.has(e.target))
+ return buildLayout(memberSchemas, memberEdges)
+}
+
+function edgeHierarchyPath(
g: dagre.graphlib.Graph,
source: string,
- target: string,
- isHierarchy: boolean
+ target: string
): string {
const s = g.node(source)
const t = g.node(target)
if (!s || !t) return ""
-
- if (isHierarchy) {
- // Straight line for parent-child
- return `M ${s.x} ${s.y + NODE_HEIGHT / 2} L ${t.x} ${t.y - NODE_HEIGHT / 2}`
- }
-
- // Curved line for relationships
- const dx = t.x - s.x
- const dy = t.y - s.y
- const cx = s.x + dx * 0.5 + dy * 0.15
- const cy = s.y + dy * 0.5 - dx * 0.15
- return `M ${s.x} ${s.y} Q ${cx} ${cy} ${t.x} ${t.y}`
+ // Straight line for parent-child, anchored to card top/bottom borders.
+ return `M ${s.x} ${s.y + NODE_HEIGHT / 2} L ${t.x} ${t.y - NODE_HEIGHT / 2}`
}
-export function OntologyGraph({ schemas, edges, selectedId, onSelect, selectedEdgeType }: Props) {
+export function OntologyGraph({ schemas, edges, selectedId, onSelect, onClear, selectedEdgeType }: Props) {
const svgRef = useRef(null)
const zoomRef = useRef>(null)
const containerRef = useRef(null)
+ const [hoveredId, setHoveredId] = useState(null)
- const g = useMemo(() => buildLayout(schemas, edges), [schemas, edges])
+ // Hover takes precedence over selection for connection tracing.
+ const focusId = hoveredId ?? selectedId
+
+ // Selecting a node collapses the canvas to a focused view of just that node
+ // and its immediate neighbors; otherwise the full ontology is laid out.
+ const g = useMemo(
+ () => (selectedId ? buildFocusedLayout(schemas, edges, selectedId) : buildLayout(schemas, edges)),
+ [schemas, edges, selectedId]
+ )
const graphInfo = useMemo(() => {
const graph = g.graph()
@@ -123,6 +183,24 @@ export function OntologyGraph({ schemas, edges, selectedId, onSelect, selectedEd
return set
}, [schemas, edges])
+ // When a node is focused (hovered or selected), collect its incident edges and
+ // neighbor nodes so everything else can be dimmed for connection tracing.
+ const { focusNodes, focusEdges } = useMemo(() => {
+ if (!focusId) {
+ return { focusNodes: null as Set | null, focusEdges: null as Set | null }
+ }
+ const nodes = new Set([focusId])
+ const incident = new Set()
+ for (const e of edges) {
+ if (e.source === focusId || e.target === focusId) {
+ incident.add(e.ref_id)
+ nodes.add(e.source)
+ nodes.add(e.target)
+ }
+ }
+ return { focusNodes: nodes, focusEdges: incident }
+ }, [focusId, edges])
+
// Compute the fit-to-graph transform
function getFitTransform(svgWidth: number, svgHeight: number): ZoomTransform {
const scaleX = svgWidth / graphInfo.width
@@ -172,28 +250,15 @@ export function OntologyGraph({ schemas, edges, selectedId, onSelect, selectedEd
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [graphInfo])
- // Select-to-focus: animate to center the selected node
+ // Esc clears the selection and returns to the full ontology view.
useEffect(() => {
- if (!selectedId || !svgRef.current || !zoomRef.current) return
- const node = g.node(selectedId)
- if (!node) return
-
- const rect = svgRef.current.getBoundingClientRect()
- const svgWidth = rect.width || 800
- const svgHeight = rect.height || 600
-
- const currentTransform = (d3Select(svgRef.current).property("__zoom") as ZoomTransform | null) ?? zoomIdentity
- const targetScale = currentTransform.k < 2.0 ? 2.0 : currentTransform.k
-
- const tx = svgWidth / 2 - node.x * targetScale
- const ty = svgHeight / 2 - node.y * targetScale
- const newTransform = zoomIdentity.translate(tx, ty).scale(targetScale)
-
- d3Select(svgRef.current)
- .transition()
- .duration(450)
- .call(zoomRef.current.transform, newTransform)
- }, [selectedId, g])
+ if (!selectedId) return
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") onClear?.()
+ }
+ window.addEventListener("keydown", onKey)
+ return () => window.removeEventListener("keydown", onKey)
+ }, [selectedId, onClear])
function handleZoomIn() {
if (!svgRef.current || !zoomRef.current) return
@@ -250,6 +315,29 @@ export function OntologyGraph({ schemas, edges, selectedId, onSelect, selectedEd
fill="oklch(0.5 0.1 200)"
/>
+ {/* Direction-coded arrowheads, used when an edge is incident to the focused node */}
+
+
+
+
+
+
{/* All transformable content inside a single for d3-zoom */}
@@ -273,16 +361,27 @@ export function OntologyGraph({ schemas, edges, selectedId, onSelect, selectedEd
{/* Hierarchy edges (CHILD_OF) */}
{edges.filter((e) => e.edge_type === "CHILD_OF").map((e) => {
- const d = edgePath(g, e.target, e.source, true)
+ const d = edgeHierarchyPath(g, e.target, e.source)
+ const emphasized = focusEdges?.has(e.ref_id) ?? false
+ const opacity = focusEdges
+ ? emphasized
+ ? 1
+ : DIM_OPACITY
+ : selectedEdgeType
+ ? DIM_OPACITY
+ : 0.85
return (
)
})}
@@ -291,23 +390,62 @@ export function OntologyGraph({ schemas, edges, selectedId, onSelect, selectedEd
{edges.map((e) => {
const key = `${e.source}→${e.target}`
if (hierarchyEdges.has(key)) return null
- const d = edgePath(g, e.source, e.target, false)
const sourceNode = g.node(e.source)
const targetNode = g.node(e.target)
if (!sourceNode || !targetNode) return null
- const mx = (sourceNode.x + targetNode.x) / 2
- const my = (sourceNode.y + targetNode.y) / 2
- const dx = targetNode.x - sourceNode.x
- const dy = targetNode.y - sourceNode.y
- const labelX = mx + dy * 0.075
- const labelY = my - dx * 0.075
-
- const isHighlighted = selectedEdgeType && e.edge_type === selectedEdgeType
- const isHighlightMode = !!selectedEdgeType
- const edgeOpacity = isHighlightMode ? (isHighlighted ? 1.0 : 0.12) : 0.6
- const edgeStroke = isHighlighted ? "oklch(0.65 0.15 200)" : "oklch(0.45 0.1 200)"
- const edgeWidth = isHighlighted ? "2" : "1"
+ // Anchor endpoints to the card borders so arrowheads stay visible
+ // and the curve doesn't run through node interiors.
+ const sp = rectBorderPoint(sourceNode.x, sourceNode.y, targetNode.x, targetNode.y)
+ const tp = rectBorderPoint(targetNode.x, targetNode.y, sourceNode.x, sourceNode.y)
+ const dx = tp.x - sp.x
+ const dy = tp.y - sp.y
+ const ctrlX = sp.x + dx * 0.5 + dy * 0.15
+ const ctrlY = sp.y + dy * 0.5 - dx * 0.15
+ const d = `M ${sp.x} ${sp.y} Q ${ctrlX} ${ctrlY} ${tp.x} ${tp.y}`
+
+ // Label at the quadratic curve's midpoint (t = 0.5).
+ const labelX = 0.25 * sp.x + 0.5 * ctrlX + 0.25 * tp.x
+ const labelY = 0.25 * sp.y + 0.5 * ctrlY + 0.25 * tp.y
+ const labelW = e.edge_type.length * 5.6 + 10
+
+ const isTypeHighlighted = selectedEdgeType === e.edge_type
+ let edgeOpacity: number
+ if (focusEdges) {
+ edgeOpacity = focusEdges.has(e.ref_id) ? 1 : DIM_OPACITY
+ } else if (selectedEdgeType) {
+ edgeOpacity = isTypeHighlighted ? 1 : 0.12
+ } else {
+ edgeOpacity = 0.6
+ }
+
+ // Direction-code edges incident to the focused node: outgoing (cyan)
+ // vs incoming (amber). Otherwise fall back to the default blue.
+ const incidentToFocus = focusEdges?.has(e.ref_id) ?? false
+ const isOutgoing = incidentToFocus && e.source === focusId
+ const isIncoming = incidentToFocus && e.target === focusId
+ const isEmphasized = incidentToFocus || isTypeHighlighted
+ let edgeStroke: string
+ let marker: string
+ let labelFill: string
+ if (isOutgoing) {
+ edgeStroke = "oklch(0.72 0.16 200)"
+ marker = "url(#arrowhead-out)"
+ labelFill = "oklch(0.78 0.15 200)"
+ } else if (isIncoming) {
+ edgeStroke = "oklch(0.80 0.15 70)"
+ marker = "url(#arrowhead-in)"
+ labelFill = "oklch(0.84 0.14 70)"
+ } else if (isTypeHighlighted) {
+ edgeStroke = "oklch(0.65 0.15 200)"
+ marker = "url(#arrowhead-rel)"
+ labelFill = "oklch(0.75 0.12 200)"
+ } else {
+ edgeStroke = "oklch(0.45 0.1 200)"
+ marker = "url(#arrowhead-rel)"
+ labelFill = "oklch(0.6 0.08 200)"
+ }
+ const edgeWidth = isEmphasized ? "2" : "1"
return (
@@ -317,7 +455,19 @@ export function OntologyGraph({ schemas, edges, selectedId, onSelect, selectedEd
stroke={edgeStroke}
strokeWidth={edgeWidth}
strokeDasharray="4 3"
- markerEnd="url(#arrowhead-rel)"
+ markerEnd={marker}
+ opacity={edgeOpacity}
+ />
+ {/* Backing pill keeps the label readable over edges and the grid */}
+
{e.edge_type}
@@ -343,13 +493,23 @@ export function OntologyGraph({ schemas, edges, selectedId, onSelect, selectedEd
const y = node.y - NODE_HEIGHT / 2
const isSelected = s.ref_id === selectedId
const attrCount = s.attributes.length
+ const nodeOpacity = focusNodes
+ ? focusNodes.has(s.ref_id)
+ ? 1
+ : DIM_OPACITY
+ : 1
+ const Icon = getSchemaIcon(s.icon)
return (
onSelect(s.ref_id)}
+ onMouseEnter={() => setHoveredId(s.ref_id)}
+ onMouseLeave={() => setHoveredId(null)}
className="cursor-pointer"
+ opacity={nodeOpacity}
>
+ {s.type}
{/* Outer halo for selected */}
{isSelected && (
+ {/* Type icon */}
+
+
{/* Type name */}
- {s.type}
+ {truncate(s.type, 13)}
- {/* Attribute count */}
-
- {attrCount} attr{attrCount !== 1 ? "s" : ""}
- {s.parent ? ` · ${s.parent}` : ""}
-
+ {/* Parent type */}
+ {s.parent && (
+
+ extends {truncate(s.parent, 16)}
+
+ )}
{/* Attribute count badge */}
+ {/* Legend overlay */}
+
+
+
+
+
+ extends (CHILD_OF)
+
+
+
+
+
+ relationship
+
+
+
+
+
+ outgoing (from focus)
+
+
+
+
+
+ incoming (to focus)
+
+
+
{/* Zoom controls overlay */}
diff --git a/src/app/admin/ontology/page.tsx b/src/app/admin/ontology/page.tsx
index 19c62cf..5bcec8b 100644
--- a/src/app/admin/ontology/page.tsx
+++ b/src/app/admin/ontology/page.tsx
@@ -6,7 +6,8 @@ import { useRouter } from "next/navigation"
import { OntologyGraph } from "./ontology-graph"
import { TypeEditor } from "./type-editor"
import { EdgeTypePanel } from "./edge-type-panel"
-import { Plus, ArrowLeft, Box, Grid2x2, Search, ArrowRight } from "lucide-react"
+import { EdgeCreatePanel, type NewEdgeParams } from "./edge-create-panel"
+import { Plus, ArrowLeft, Box, Grid2x2, Search, ArrowRight, HelpCircle } from "lucide-react"
import { useUserStore } from "@/stores/user-store"
const OntologyGraph3D = dynamic(
@@ -15,10 +16,10 @@ const OntologyGraph3D = dynamic(
)
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
-import { useSchemaStore } from "@/stores/schema-store"
+import { useSchemaStore, serializeAttributes } from "@/stores/schema-store"
import { isMocksEnabled } from "@/lib/mock-data"
import { SMALL_SCHEMAS, SMALL_EDGES } from "./mock-small"
-import type { SchemaNode } from "@/lib/schema-types"
+import type { SchemaNode, SchemaEdge, SchemaAttribute } from "@/lib/schema-types"
export default function OntologyPage() {
const router = useRouter()
@@ -31,6 +32,13 @@ export default function OntologyPage() {
const [sidebarTab, setSidebarTab] = useState<"nodes" | "edges">("nodes")
const [selectedEdgeType, setSelectedEdgeType] = useState(null)
const [edgeSearch, setEdgeSearch] = useState("")
+ const [edgeError, setEdgeError] = useState(null)
+ // Non-null while the "new relationship" panel is open; the optional source/
+ // target prefill the form (set when a connection is drawn on the graph).
+ const [edgeCreate, setEdgeCreate] = useState<{ source?: string; target?: string } | null>(null)
+ // Non-null while a draft (unsaved) new node type is being authored.
+ const [draftType, setDraftType] = useState(null)
+ const [showHelp, setShowHelp] = useState(false)
useEffect(() => {
if (isMocksEnabled()) {
@@ -69,12 +77,15 @@ export default function OntologyPage() {
const handleSwitchToEdges = useCallback(() => {
setSidebarTab("edges")
setSelectedId(null)
+ setDraftType(null)
+ setEdgeCreate(null)
}, [])
const handleSwitchToNodes = useCallback(() => {
setSidebarTab("nodes")
setSelectedEdgeType(null)
setEdgeSearch("")
+ setEdgeCreate(null)
}, [])
const handleUpdateSchema = useCallback(
@@ -90,30 +101,41 @@ export default function OntologyPage() {
[isAdmin, store]
)
- const handleAddType = useCallback(async () => {
+ // Open a draft "new type" form. Nothing is persisted until the user clicks
+ // Create — clicking + no longer writes a NewType record to the server.
+ const handleStartAddType = useCallback(() => {
if (!isAdmin) return
- // Find next available name
const existing = new Set(store.schemas.map((s) => s.type))
let n = 1
while (existing.has(`NewType${n}`)) n++
- const id = `s-${Date.now()}`
- const newSchema: SchemaNode = {
- ref_id: id,
+ setEdgeCreate(null)
+ setSelectedId(null)
+ setSchemaError(null)
+ setDraftType({
+ ref_id: `s-${Date.now()}`,
type: `NewType${n}`,
parent: "Thing",
color: "#64748b",
node_key: "name",
attributes: [{ key: "name", type: "string", required: true }],
- }
- try {
- await store.addSchema(newSchema)
- setSchemaError(null)
- } catch (err) {
- setSchemaError(err instanceof Error ? err.message : "Failed to save schema")
- }
- setSelectedId(id)
- }, [isAdmin, store])
+ })
+ }, [isAdmin, store.schemas])
+
+ const handleCreateType = useCallback(
+ async (draft: SchemaNode) => {
+ if (!isAdmin) return
+ try {
+ const refId = await store.addSchema(draft)
+ setSchemaError(null)
+ setDraftType(null)
+ setSelectedId(refId)
+ } catch (err) {
+ setSchemaError(err instanceof Error ? err.message : "Failed to create type")
+ }
+ },
+ [isAdmin, store]
+ )
const handleDeleteSchema = useCallback(
(refId: string) => {
@@ -129,6 +151,92 @@ export default function OntologyPage() {
[store.edges, selectedEdgeType]
)
+ const typeToRefId = useCallback(
+ (typeName: string) => store.schemas.find((s) => s.type === typeName)?.ref_id ?? typeName,
+ [store.schemas]
+ )
+
+ const buildEdge = useCallback(
+ (
+ sourceType: string,
+ targetType: string,
+ edgeType: string,
+ attributes: SchemaAttribute[]
+ ): SchemaEdge => ({
+ ref_id: `e-${Date.now()}-${Math.round(Math.random() * 1e6)}`,
+ source: typeToRefId(sourceType),
+ target: typeToRefId(targetType),
+ // Match the backend's normalization so optimistic UI lines up with the saved value.
+ edge_type: edgeType.trim().toUpperCase().replace(/\s+/g, "_"),
+ source_type: sourceType,
+ target_type: targetType,
+ attributes: serializeAttributes(attributes),
+ }),
+ [typeToRefId]
+ )
+
+ const handleCreateEdge = useCallback(
+ async ({ sourceType, targetType, edgeType, attributes }: NewEdgeParams) => {
+ if (!isAdmin) return
+ const edge = buildEdge(sourceType, targetType, edgeType, attributes)
+ try {
+ await store.addEdge(edge)
+ setEdgeError(null)
+ setEdgeCreate(null)
+ setSelectedId(null)
+ setSidebarTab("edges")
+ setSelectedEdgeType(edge.edge_type)
+ } catch (err) {
+ setEdgeError(err instanceof Error ? err.message : "Failed to create relationship")
+ }
+ },
+ [isAdmin, buildEdge, store]
+ )
+
+ const handleAddConnection = useCallback(
+ async (sourceType: string, targetType: string) => {
+ if (!isAdmin || !selectedEdgeType) return
+ const edge = buildEdge(sourceType, targetType, selectedEdgeType, [])
+ try {
+ await store.addEdge(edge)
+ setEdgeError(null)
+ } catch (err) {
+ setEdgeError(err instanceof Error ? err.message : "Failed to add connection")
+ }
+ },
+ [isAdmin, selectedEdgeType, buildEdge, store]
+ )
+
+ const handleDeleteConnection = useCallback(
+ (refId: string) => {
+ if (!isAdmin) return
+ store.removeEdge(refId)
+ },
+ [isAdmin, store]
+ )
+
+ const handleSaveEdgeAttributes = useCallback(
+ async (attrs: SchemaAttribute[]) => {
+ if (!isAdmin || !selectedEdgeType) return
+ const serialized = serializeAttributes(attrs)
+ const targets = store.edges.filter((e) => e.edge_type === selectedEdgeType)
+ try {
+ await Promise.all(targets.map((e) => store.updateEdge({ ...e, attributes: serialized })))
+ setEdgeError(null)
+ } catch (err) {
+ setEdgeError(err instanceof Error ? err.message : "Failed to update attributes")
+ }
+ },
+ [isAdmin, selectedEdgeType, store]
+ )
+
+ const handleDeleteEdgeType = useCallback(async () => {
+ if (!isAdmin || !selectedEdgeType) return
+ const targets = store.edges.filter((e) => e.edge_type === selectedEdgeType)
+ await Promise.all(targets.map((e) => store.removeEdge(e.ref_id)))
+ setSelectedEdgeType(null)
+ }, [isAdmin, selectedEdgeType, store])
+
return (
{/* Left: Type list */}
@@ -168,6 +276,16 @@ export default function OntologyPage() {
+ setShowHelp((v) => !v)}
+ className={`h-7 w-7 p-0 hover:text-foreground ${showHelp ? "text-foreground" : "text-muted-foreground"}`}
+ title="How to add types & relationships"
+ >
+
+
+
: }
- {/* Only show + button in nodes tab */}
- {sidebarTab === "nodes" && (
-
-
-
- )}
+
{
+ setEdgeError(null)
+ setEdgeCreate({})
+ }
+ }
+ disabled={!isAdmin}
+ title={sidebarTab === "nodes" ? "Add type" : "Add relationship"}
+ className="h-7 w-7 p-0"
+ >
+
+
+ {/* Help / tips */}
+ {showHelp && (
+
+ {sidebarTab === "nodes" ? (
+ <>
+
Add a type (node)
+
+ Click the + button above.
+ Name it and pick a parent to inherit attributes.
+ Add attributes (the fields each node holds).
+ Choose a unique key , then Create type .
+
+
+ Click any type to edit it (changes save on Save ). Selecting a node focuses its neighborhood; press Esc to zoom back out.
+
+ >
+ ) : (
+ <>
+
Add a relationship (edge)
+
+ Click the + button above.
+ Name it, e.g. AUTHORED_BY .
+ Pick the From and To types (arrow points From → To).
+ Click Create relationship .
+
+
+ Select a relationship to add/remove its connections or edit its attributes.
+
+ >
+ )}
+
+ )}
+
{/* Search input */}
@@ -225,7 +382,11 @@ export default function OntologyPage() {
{visibleSchemas.map((schema) => (
setSelectedId(schema.ref_id)}
+ onClick={() => {
+ setDraftType(null)
+ setEdgeCreate(null)
+ setSelectedId(schema.ref_id)
+ }}
className={`flex items-center gap-3 w-full rounded-md px-3 py-2 text-left transition-colors ${
selectedId === schema.ref_id
? "bg-primary/10 text-foreground"
@@ -295,32 +456,73 @@ export default function OntologyPage() {
edges={store.edges}
selectedId={selectedId}
onSelect={setSelectedId}
+ onClear={() => setSelectedId(null)}
selectedEdgeType={selectedEdgeType}
/>
)}
- {/* Right panel */}
- {sidebarTab === "nodes" && selected && (
+ {/* Right panel — create flow takes precedence over the inspectors */}
+ {edgeCreate ? (
+
{
+ setEdgeCreate(null)
+ setEdgeError(null)
+ }}
+ error={edgeError ?? undefined}
+ onClearError={() => setEdgeError(null)}
+ />
+ ) : draftType ? (
+ {
+ setDraftType(null)
+ setSchemaError(null)
+ }}
+ error={schemaError ?? undefined}
+ onClearError={() => setSchemaError(null)}
+ />
+ ) : sidebarTab === "nodes" && selected ? (
setSelectedId(null)}
error={schemaError ?? undefined}
onClearError={() => setSchemaError(null)}
/>
- )}
- {sidebarTab === "edges" && selectedEdgeType !== null && (
+ ) : sidebarTab === "edges" && selectedEdgeType !== null ? (
setSelectedEdgeType(null)}
+ onAddConnection={handleAddConnection}
+ onDeleteConnection={handleDeleteConnection}
+ onSaveAttributes={handleSaveEdgeAttributes}
+ onDeleteType={handleDeleteEdgeType}
+ error={edgeError ?? undefined}
+ onClearError={() => setEdgeError(null)}
/>
- )}
+ ) : null}
)
}
diff --git a/src/app/admin/ontology/type-editor.tsx b/src/app/admin/ontology/type-editor.tsx
index c793415..40b9d54 100644
--- a/src/app/admin/ontology/type-editor.tsx
+++ b/src/app/admin/ontology/type-editor.tsx
@@ -1,7 +1,8 @@
"use client"
-import { useCallback, useState } from "react"
-import { X, Plus, Trash2 } from "lucide-react"
+import { useMemo, useState } from "react"
+import { X, Plus, Trash2, Lock } from "lucide-react"
+import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
@@ -24,7 +25,11 @@ interface Props {
schema: SchemaNode
allSchemas: SchemaNode[]
edges: SchemaEdge[]
- onUpdate: (schema: SchemaNode) => void
+ canEdit: boolean
+ /** Draft (unsaved) new type — shows a Create button and persists nothing until clicked. */
+ isNew?: boolean
+ onSave: (schema: SchemaNode) => void
+ onCreate?: (schema: SchemaNode) => void
onDelete: (refId: string) => void
onClose: () => void
error?: string
@@ -38,43 +43,72 @@ function parseNodeKeySegments(nodeKey: string, type: string): string[] {
return withoutPrefix ? withoutPrefix.split("-").filter(Boolean) : []
}
-export function TypeEditor({ schema, allSchemas, edges, onUpdate, onDelete, onClose, error, onClearError }: Props) {
+export function TypeEditor({ schema: schemaProp, allSchemas, edges, canEdit, isNew, onSave, onCreate, onDelete, onClose, error, onClearError }: Props) {
const [confirmDelete, setConfirmDelete] = useState(false)
-
- const update = useCallback(
- (partial: Partial) => {
- onClearError?.()
- onUpdate({ ...schema, ...partial })
- },
- [schema, onUpdate, onClearError]
+ // Local working copy — nothing is persisted until Save/Create. The parent
+ // remounts this component (key) when the selected type changes, re-seeding it.
+ const [draft, setDraft] = useState(schemaProp)
+ const schema = draft
+
+ const dirty = useMemo(
+ () => JSON.stringify(draft) !== JSON.stringify(schemaProp),
+ [draft, schemaProp]
)
- const updateAttribute = useCallback(
- (index: number, partial: Partial) => {
- const attrs = [...schema.attributes]
- attrs[index] = { ...attrs[index], ...partial }
- update({ attributes: attrs })
- },
- [schema, update]
- )
+ const update = (partial: Partial) => {
+ onClearError?.()
+ setDraft((d) => ({ ...d, ...partial }))
+ }
+
+ const updateAttribute = (index: number, partial: Partial) => {
+ onClearError?.()
+ setDraft((d) => {
+ const attrs = [...d.attributes]
+ const prev = attrs[index]
+ const next = { ...prev, ...partial }
+ attrs[index] = next
+
+ // Keep node_key consistent with attribute edits: a key segment can't be
+ // optional (backend rejects it), and must track renames.
+ let segments = parseNodeKeySegments(d.node_key ?? "", d.type)
+ if (partial.key !== undefined && prev.key && segments.includes(prev.key)) {
+ segments = segments.map((s) => (s === prev.key ? next.key : s)).filter(Boolean)
+ }
+ if (partial.required === false && segments.includes(next.key)) {
+ segments = segments.filter((s) => s !== next.key)
+ }
+ return { ...d, attributes: attrs, node_key: segments.join("-") }
+ })
+ }
- const addAttribute = useCallback(() => {
- update({
- attributes: [
- ...schema.attributes,
- { key: "", type: "string", required: false },
- ],
+ const addAttribute = () => {
+ setDraft((d) => ({
+ ...d,
+ attributes: [...d.attributes, { key: "", type: "string", required: false }],
+ }))
+ }
+
+ const removeAttribute = (index: number) => {
+ setDraft((d) => {
+ const removed = d.attributes[index]
+ const attrs = d.attributes.filter((_, i) => i !== index)
+ const segments = parseNodeKeySegments(d.node_key ?? "", d.type).filter(
+ (s) => s !== removed?.key
+ )
+ return { ...d, attributes: attrs, node_key: segments.join("-") }
})
- }, [schema, update])
-
- const removeAttribute = useCallback(
- (index: number) => {
- update({
- attributes: schema.attributes.filter((_, i) => i !== index),
- })
- },
- [schema, update]
- )
+ }
+
+ // Set the unique key from the picker. Any selected attribute that's optional
+ // is promoted to required, since node_key segments must be required.
+ const setNodeKey = (vals: string[]) => {
+ onClearError?.()
+ setDraft((d) => ({
+ ...d,
+ attributes: d.attributes.map((a) => (vals.includes(a.key) ? { ...a, required: true } : a)),
+ node_key: vals.join("-"),
+ }))
+ }
const parentOptions = allSchemas
.filter((s) => s.ref_id !== schema.ref_id)
@@ -106,12 +140,14 @@ export function TypeEditor({ schema, allSchemas, edges, onUpdate, onDelete, onCl
...inheritedAttrOptions,
]
- // node_key multi-select: only own required attributes
+ // node_key multi-select: any named attribute (optional ones get promoted to
+ // required on selection, since the backend forbids optional key segments).
const nodeKeyOptions = schema.attributes
- .filter((a) => a.key && a.required)
+ .filter((a) => a.key)
.map((a) => ({ value: a.key, label: a.key }))
const selectedNodeKeys = parseNodeKeySegments(schema.node_key ?? "", schema.type)
+ const nodeKeyValid = selectedNodeKeys.length > 0
return (
@@ -132,7 +168,26 @@ export function TypeEditor({ schema, allSchemas, edges, onUpdate, onDelete, onCl
-
+
+ {!canEdit && (
+
+
+
+ Read-only — admin access required to edit.
+
+
+ )}
+
+ {isNew && (
+
+
+ A type is a kind of node
+ (e.g. Person, Document). Name it, pick a parent to inherit attributes from, add
+ its own attributes, choose a unique key, then{" "}
+ Create .
+
+
+ )}
{/* Type name */}
@@ -159,6 +214,9 @@ export function TypeEditor({ schema, allSchemas, edges, onUpdate, onDelete, onCl
...parentOptions.map((t) => ({ value: t, label: t })),
]}
/>
+
+ Inherits the parent's attributes. Use “Thing” for a top-level type.
+
{/* Title Property */}
@@ -172,6 +230,9 @@ export function TypeEditor({ schema, allSchemas, edges, onUpdate, onDelete, onCl
options={allAttrOptions}
placeholder="None"
/>
+
+ Which attribute shows as the node's title in the graph.
+
{/* Description Property */}
@@ -185,6 +246,9 @@ export function TypeEditor({ schema, allSchemas, edges, onUpdate, onDelete, onCl
options={allAttrOptions}
placeholder="None"
/>
+
+ Optional attribute shown as the node's subtitle.
+
{/* Color */}
@@ -215,13 +279,18 @@ export function TypeEditor({ schema, allSchemas, edges, onUpdate, onDelete, onCl
update({ node_key: vals.join("-") })}
+ onChange={setNodeKey}
options={nodeKeyOptions}
- placeholder="Select required attributes…"
+ placeholder="Select attributes…"
/>
- Only required attributes. Combined to uniquely identify nodes.
+ Combined to uniquely identify nodes. Selecting an optional attribute marks it required.
+ {!nodeKeyValid && (
+
+ Pick at least one attribute as the unique key.
+
+ )}
@@ -242,6 +311,9 @@ export function TypeEditor({ schema, allSchemas, edges, onUpdate, onDelete, onCl
Add
+ Fields stored on each node of this type. Toggle Required for mandatory ones.
+
{/* Inherited attributes (read-only) */}
{(schema.inherited_attributes ?? []).length > 0 && (
@@ -357,50 +429,72 @@ export function TypeEditor({ schema, allSchemas, edges, onUpdate, onDelete, onCl