diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 872b82a..c3e7934 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -3,14 +3,15 @@ import { useEffect } from "react" import { useRouter } from "next/navigation" import { useUserStore } from "@/stores/user-store" +import { AuthGuard } from "@/components/auth/auth-guard" /** - * Single admin guard for the whole `/admin/*` console. Replaces the per-page - * redirects that previously lived in settings/ontology/domains/reviews. Auth - * itself is established at the app root by AuthGuard; here we only gate on the - * resolved isAdmin flag. + * Single admin guard for the whole `/admin/*` console. AuthGuard establishes + * auth + the isAdmin flag for the section (on a direct load/refresh of an admin + * route, not just when navigating in from `/`); the inner gate then redirects + * non-admins to `/`. */ -export default function AdminLayout({ children }: { children: React.ReactNode }) { +function AdminGate({ children }: { children: React.ReactNode }) { const router = useRouter() const isAuthenticated = useUserStore((s) => s.isAuthenticated) const isAdmin = useUserStore((s) => s.isAdmin) @@ -23,3 +24,11 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) return <>{children} } + +export default function AdminLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} diff --git a/src/app/admin/ontology/edge-create-panel.tsx b/src/app/admin/ontology/edge-create-panel.tsx new file mode 100644 index 0000000..cac875c --- /dev/null +++ b/src/app/admin/ontology/edge-create-panel.tsx @@ -0,0 +1,249 @@ +"use client" + +import { useState } from "react" +import { X, Plus, Trash2, GitMerge } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Switch } from "@/components/ui/switch" +import { Separator } from "@/components/ui/separator" +import { SelectCustom } from "@/components/ui/select-custom" +import { MAX_LENGTHS } from "@/lib/input-limits" +import type { SchemaNode, SchemaAttribute } from "@/lib/schema-types" + +const ATTR_TYPES = ["string", "int", "float", "boolean", "date"] +const WILDCARD = "*" + +export interface NewEdgeParams { + sourceType: string + targetType: string + edgeType: string + attributes: SchemaAttribute[] +} + +interface Props { + allSchemas: SchemaNode[] + initialSource?: string + initialTarget?: string + onCreate: (params: NewEdgeParams) => void + onClose: () => void + error?: string + onClearError?: () => void +} + +/** + * Right-panel form for creating a new relationship (edge schema). Source/target + * are picked by type name; the backend keys edge schemas off names and accepts + * "*" as a wildcard for either endpoint. + */ +export function EdgeCreatePanel({ + allSchemas, + initialSource, + initialTarget, + onCreate, + onClose, + error, + onClearError, +}: Props) { + const [edgeType, setEdgeType] = useState("") + const [sourceType, setSourceType] = useState(initialSource ?? "") + const [targetType, setTargetType] = useState(initialTarget ?? "") + const [attributes, setAttributes] = useState([]) + + const sortedTypes = [...allSchemas] + .map((s) => ({ value: s.type, label: s.type })) + .sort((a, b) => a.label.localeCompare(b.label)) + + const sourceOptions = [ + { value: "", label: "Select type…" }, + { value: WILDCARD, label: "Any (*)" }, + ...sortedTypes, + ] + const targetOptions = sourceOptions + + const canCreate = !!edgeType.trim() && !!sourceType && !!targetType + + const addAttr = () => + setAttributes((a) => [...a, { key: "", type: "string", required: false }]) + const updateAttr = (i: number, partial: Partial) => + setAttributes((a) => a.map((x, idx) => (idx === i ? { ...x, ...partial } : x))) + const removeAttr = (i: number) => + setAttributes((a) => a.filter((_, idx) => idx !== i)) + + const handleCreate = () => { + if (!canCreate) return + onClearError?.() + onCreate({ + sourceType, + targetType, + edgeType: edgeType.trim(), + attributes: attributes.filter((a) => a.key.trim()), + }) + } + + return ( +
+ {/* Header */} +
+
+

+ New Relationship +

+
+ +

+ {edgeType.trim() || "edge type"} +

+
+
+ +
+ + {/* Body */} +
+
+

+ A relationship connects two + types in one direction, e.g.{" "} + Person —AUTHORED_BY→ Document. + Name it, pick the From and To types, then{" "} + Create. +

+
+ {/* Edge type name */} +
+ + { + onClearError?.() + setEdgeType(e.target.value) + }} + placeholder="e.g. POSTED, MENTIONS" + maxLength={MAX_LENGTHS.SCHEMA_TYPE_NAME} + className="h-8 text-sm bg-muted/50 border-border/50 font-mono" + /> +

+ Saved uppercased with underscores (e.g. “has clip” → HAS_CLIP). +

+
+ + {/* Source → Target */} +
+ + +
+
+ + +

+ The arrow points From → To. Choose “Any (*)” to allow any type on that end. +

+
+ + + + {/* Attributes */} +
+
+ + +
+ {attributes.length === 0 ? ( +

No attributes (optional)

+ ) : ( +
+ {attributes.map((attr, i) => ( +
+
+
+ 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"} + +
+
+ +
+ ))} +
+ )} +
+
+ + {/* Footer */} +
+ {error &&

{error}

} + +
+
+ ) +} diff --git a/src/app/admin/ontology/edge-type-panel.tsx b/src/app/admin/ontology/edge-type-panel.tsx index 2fe2810..5959b9e 100644 --- a/src/app/admin/ontology/edge-type-panel.tsx +++ b/src/app/admin/ontology/edge-type-panel.tsx @@ -1,38 +1,107 @@ "use client" -import { X, GitMerge } from "lucide-react" +import { useMemo, useState } from "react" +import { X, GitMerge, Plus, Trash2, Lock } from "lucide-react" import { Button } from "@/components/ui/button" import { Separator } from "@/components/ui/separator" import { Label } from "@/components/ui/label" -import type { SchemaEdge, SchemaNode } from "@/lib/schema-types" +import { Switch } from "@/components/ui/switch" +import { SelectCustom } from "@/components/ui/select-custom" +import { MAX_LENGTHS } from "@/lib/input-limits" +import type { SchemaAttribute, SchemaEdge, SchemaNode } from "@/lib/schema-types" + +const ATTR_TYPES = ["string", "int", "float", "boolean", "date"] +const WILDCARD = "*" +// Edge-level keys that are infrastructure, not user-facing attributes. +const RESERVED_ATTR_KEYS = new Set(["ref_id", "edge_key", "display_name"]) interface Props { edgeType: string edges: SchemaEdge[] allSchemas: SchemaNode[] + canEdit: boolean onClose: () => void + onAddConnection: (sourceType: string, targetType: string) => void + onDeleteConnection: (refId: string) => void + onSaveAttributes: (attrs: SchemaAttribute[]) => void + onDeleteType: () => void + error?: string + onClearError?: () => void } -export function EdgeTypePanel({ edgeType, edges, allSchemas, onClose }: Props) { - const refIdToType = Object.fromEntries(allSchemas.map((s) => [s.ref_id, s])) - - // Deduplicate attributes across all edges of this type - const attrMap = new Map() - let hasAttributes = false +/** Collapse the per-connection attribute maps into one deduped attribute list. */ +function dedupeAttributes(edges: SchemaEdge[]): SchemaAttribute[] { + const map = new Map() for (const e of edges) { if (!e.attributes) continue - const attrs = e.attributes - for (const [key, rawType] of Object.entries(attrs)) { - if (typeof rawType !== "string") continue - hasAttributes = true - if (!attrMap.has(key)) { - const optional = rawType.startsWith("?") - attrMap.set(key, { type: rawType.replace(/^\?/, ""), optional }) + for (const [key, raw] of Object.entries(e.attributes)) { + if (typeof raw !== "string" || RESERVED_ATTR_KEYS.has(key)) continue + if (!map.has(key)) { + const optional = raw.startsWith("?") + map.set(key, { key, type: raw.replace(/^\?/, ""), required: !optional }) } } } + return Array.from(map.values()) +} - const attrEntries = Array.from(attrMap.entries()) +export function EdgeTypePanel({ + edgeType, + edges, + allSchemas, + canEdit, + onClose, + onAddConnection, + onDeleteConnection, + onSaveAttributes, + onDeleteType, + error, + onClearError, +}: Props) { + const refIdToType = useMemo( + () => Object.fromEntries(allSchemas.map((s) => [s.ref_id, s])), + [allSchemas] + ) + + // Local working copy of the type's attributes (remounted per type via `key`). + const [attributes, setAttributes] = useState(() => dedupeAttributes(edges)) + const initialAttrs = useMemo(() => dedupeAttributes(edges), [edges]) + const dirty = useMemo( + () => JSON.stringify(attributes) !== JSON.stringify(initialAttrs), + [attributes, initialAttrs] + ) + + const [addSource, setAddSource] = useState("") + const [addTarget, setAddTarget] = useState("") + const [confirmDelete, setConfirmDelete] = useState(false) + + const sortedTypes = useMemo( + () => + [...allSchemas] + .map((s) => ({ value: s.type, label: s.type })) + .sort((a, b) => a.label.localeCompare(b.label)), + [allSchemas] + ) + const typeOptions = [ + { value: "", label: "Select…" }, + { value: WILDCARD, label: "Any (*)" }, + ...sortedTypes, + ] + + const addAttr = () => + setAttributes((a) => [...a, { key: "", type: "string", required: false }]) + const updateAttr = (i: number, partial: Partial) => + setAttributes((a) => a.map((x, idx) => (idx === i ? { ...x, ...partial } : x))) + const removeAttr = (i: number) => + setAttributes((a) => a.filter((_, idx) => idx !== i)) + + const handleAddConnection = () => { + if (!addSource || !addTarget) return + onClearError?.() + onAddConnection(addSource, addTarget) + setAddSource("") + setAddTarget("") + } return (
@@ -40,7 +109,7 @@ export function EdgeTypePanel({ edgeType, edges, allSchemas, onClose }: Props) {

- 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 */}
- {/* Attributes section — only render if any edge has attributes */} - {hasAttributes && ( - <> - + + + {/* Attributes section */} +
+
+ + {canEdit && ( + + )} +
+ + {attributes.length === 0 ? ( +

No 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"} + +
+
+ +
+ ) : (
- {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" : ""} + + +
+ )} +
+ + {/* Footer */} + {canEdit && ( +
+ {error &&

{error}

} + {confirmDelete ? ( +
+ Delete this 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 */}
+ + - {/* Only show + button in nodes tab */} - {sidebarTab === "nodes" && ( - - )} +
+ {/* Help / tips */} + {showHelp && ( +
+ {sidebarTab === "nodes" ? ( + <> +

Add a type (node)

+
    +
  1. Click the + button above.
  2. +
  3. Name it and pick a parent to inherit attributes.
  4. +
  5. Add attributes (the fields each node holds).
  6. +
  7. Choose a unique key, then Create type.
  8. +
+

+ 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)

+
    +
  1. Click the + button above.
  2. +
  3. Name it, e.g. AUTHORED_BY.
  4. +
  5. Pick the From and To types (arrow points From → To).
  6. +
  7. Click Create relationship.
  8. +
+

+ 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) => (
- {/* 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 */}
{/* 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
)} + {/* Footer */} -
- {error && ( -

{error}

- )} - {schema.type === "Thing" ? ( -

- Root type cannot be deleted -

- ) : confirmDelete ? ( -
- Delete this type? -
- + {canEdit && ( +
+ {error &&

{error}

} + + {isNew ? ( + + ) : ( + <> -
-
- ) : ( - - )} -
+ {schema.type === "Thing" ? ( +

+ Root type cannot be deleted +

+ ) : confirmDelete ? ( +
+ Delete this type? +
+ + +
+
+ ) : ( + + )} + + )} +
+ )} ) } diff --git a/src/lib/__tests__/type-editor.test.tsx b/src/lib/__tests__/type-editor.test.tsx index 497b1c1..0c06778 100644 --- a/src/lib/__tests__/type-editor.test.tsx +++ b/src/lib/__tests__/type-editor.test.tsx @@ -17,7 +17,8 @@ const defaultProps = { schema: baseSchema, allSchemas: [baseSchema], edges: [] as SchemaEdge[], - onUpdate: vi.fn(), + canEdit: true, + onSave: vi.fn(), onDelete: vi.fn(), onClose: vi.fn(), } @@ -42,11 +43,11 @@ describe("TypeEditor – error prop", () => { it("calls onClearError when an attribute is edited", async () => { const onClearError = vi.fn() - const onUpdate = vi.fn() + const onSave = vi.fn() render( diff --git a/src/stores/schema-store.ts b/src/stores/schema-store.ts index 245531a..c32a32e 100644 --- a/src/stores/schema-store.ts +++ b/src/stores/schema-store.ts @@ -13,8 +13,11 @@ interface SchemaState { setEdges: (edges: SchemaEdge[]) => void setLoading: (loading: boolean) => void updateSchema: (updated: SchemaNode) => Promise - addSchema: (schema: SchemaNode) => Promise + addSchema: (schema: SchemaNode) => Promise removeSchema: (refId: string) => Promise + addEdge: (edge: SchemaEdge) => Promise + updateEdge: (edge: SchemaEdge) => Promise + removeEdge: (refId: string) => Promise fetchAll: () => Promise } @@ -87,7 +90,7 @@ export const useSchemaStore = create((set) => ({ // Optimistic add set((s) => ({ schemas: [...s.schemas, schema] })) - if (isMocksEnabled()) return + if (isMocksEnabled()) return schema.ref_id try { const res = await api.post<{ ref_id?: string }>("/schema", { @@ -109,6 +112,7 @@ export const useSchemaStore = create((set) => ({ ), })) } + return res.ref_id ?? schema.ref_id } catch (err) { // Rollback set((s) => ({ schemas: s.schemas.filter((x) => x.ref_id !== schema.ref_id) })) @@ -133,6 +137,81 @@ export const useSchemaStore = create((set) => ({ } }, + addEdge: async (edge) => { + // Optimistic add + set((s) => ({ edges: [...s.edges, edge] })) + + if (isMocksEnabled()) return + + try { + // The backend keys edge schemas off type NAMES, not ref_ids. + const res = await api.post<{ ref_id?: string }>("/schema/edge", { + source: edge.source_type ?? edge.source, + target: edge.target_type ?? edge.target, + edge_type: edge.edge_type, + ...(edge.attributes && Object.keys(edge.attributes).length + ? { attributes: edge.attributes } + : {}), + }) + + // Replace the optimistic temp ref_id with the server's. + if (res.ref_id) { + set((s) => ({ + edges: s.edges.map((x) => + x.ref_id === edge.ref_id ? { ...x, ref_id: res.ref_id! } : x + ), + })) + } + } catch (err) { + // Rollback + set((s) => ({ edges: s.edges.filter((x) => x.ref_id !== edge.ref_id) })) + const body = err instanceof Response ? await err.json().catch(() => ({})) : {} + throw new Error( + (body as { message?: string }).message || "Failed to save relationship" + ) + } + }, + + updateEdge: async (edge) => { + const prev = useSchemaStore.getState().edges + // Optimistic update + set((s) => ({ + edges: s.edges.map((x) => (x.ref_id === edge.ref_id ? edge : x)), + })) + + if (isMocksEnabled()) return + + try { + await api.put(`/schema/edge/${edge.ref_id}`, { + edge_type: edge.edge_type, + attributes: edge.attributes ?? {}, + }) + } catch (err) { + // Rollback + set({ edges: prev }) + const body = err instanceof Response ? await err.json().catch(() => ({})) : {} + throw new Error( + (body as { message?: string }).message || "Failed to update relationship" + ) + } + }, + + removeEdge: async (refId) => { + const prev = useSchemaStore.getState().edges + // Optimistic remove + set((s) => ({ edges: s.edges.filter((x) => x.ref_id !== refId) })) + + if (isMocksEnabled()) return + + try { + await api.delete(`/schema/edge/${refId}`) + } catch (err) { + console.error("Failed to delete relationship:", err) + // Rollback + set({ edges: prev }) + } + }, + fetchAll: async () => { set({ loading: true }) try {