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
8 changes: 1 addition & 7 deletions src/app/admin/domains/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,7 @@ import {
type SchemaDomainsResponse,
} from "@/lib/graph-api"
import { MAX_LENGTHS } from "@/lib/input-limits"

const DEFAULT_DOMAIN = "entity"

/** The domain a schema type belongs to (lowercased; defaults to "entity"). */
function domainKeyOf(s: SchemaNode): string {
return (s.domain || DEFAULT_DOMAIN).toLowerCase()
}
import { DEFAULT_DOMAIN, domainKeyOf } from "@/lib/schema-domains"

function capitalize(s: string): string {
return s ? s.charAt(0).toUpperCase() + s.slice(1) : s
Expand Down
95 changes: 95 additions & 0 deletions src/app/admin/ontology/domain-filter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"use client"

import { useState } from "react"
import { ChevronDown, ChevronRight, ListFilter } from "lucide-react"
import { Checkbox } from "@/components/ui/checkbox"
import type { SchemaDomainOption } from "@/lib/schema-domains"

interface Props {
domains: SchemaDomainOption[]
/** Domain keys currently hidden from the graph. */
disabled: ReadonlySet<string>
onToggle: (key: string) => void
onAll: () => void
onNone: () => void
}

// Collapsible "Domains" strip in the ontology sidebar: one checkbox per domain
// (with its type count) plus All / None shortcuts. Collapsed by default; the
// header keeps showing "enabled/total" so an active filter stays visible
// across reloads.
export function DomainFilter({ domains, disabled, onToggle, onAll, onNone }: Props) {
const [open, setOpen] = useState(false)
if (domains.length === 0) return null

const enabledCount = domains.filter((d) => !disabled.has(d.key)).length
const filtering = enabledCount < domains.length

return (
<div className="relative z-10 border-b border-border">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className="flex w-full items-center gap-2 px-3 py-1.5 text-[11px] font-medium text-muted-foreground hover:text-foreground transition-colors"
>
<ListFilter className={`h-3.5 w-3.5 shrink-0 ${filtering ? "text-primary" : ""}`} />
<span className="flex-1 text-left">Domains</span>
<span className={`font-mono text-[10px] ${filtering ? "text-primary" : "text-muted-foreground/60"}`}>
{enabledCount}/{domains.length}
</span>
{open ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
)}
</button>

{open && (
<div className="px-2 pb-2">
<div className="flex items-center justify-end gap-1 px-1 pb-1 text-[10px] text-muted-foreground">
<button
type="button"
onClick={onAll}
disabled={!filtering}
className="rounded px-1 hover:text-foreground disabled:opacity-40 disabled:hover:text-muted-foreground"
>
All
</button>
<span className="text-muted-foreground/40">·</span>
<button
type="button"
onClick={onNone}
disabled={enabledCount === 0}
className="rounded px-1 hover:text-foreground disabled:opacity-40 disabled:hover:text-muted-foreground"
>
None
</button>
</div>
<div className="space-y-0.5">
{domains.map((d) => {
const enabled = !disabled.has(d.key)
return (
<label
key={d.key}
className={`flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 text-xs transition-colors hover:bg-muted/50 ${
enabled ? "text-foreground" : "text-muted-foreground"
}`}
>
<Checkbox
checked={enabled}
onChange={() => onToggle(d.key)}
ariaLabel={`Show ${d.label} types`}
className="h-3.5 w-3.5"
/>
<span className="min-w-0 flex-1 truncate">{d.label}</span>
<span className="font-mono text-[10px] text-muted-foreground/60">{d.count}</span>
</label>
)
})}
</div>
</div>
)}
</div>
)
}
138 changes: 128 additions & 10 deletions src/app/admin/ontology/page.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,77 @@
"use client"

import { useCallback, useEffect, useMemo, useState } from "react"
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"
import { useRouter } from "next/navigation"
import { OntologyGraph } from "./ontology-graph"
import { OntologyNeo4jGraph } from "./ontology-neo4j-graph"
import { TypeEditor } from "./type-editor"
import { EdgeTypePanel } from "./edge-type-panel"
import { EdgeCreatePanel, type NewEdgeParams } from "./edge-create-panel"
import { OntologyAgentPanel } from "./ontology-agent-panel"
import { DomainFilter } from "./domain-filter"
import { Plus, ArrowLeft, Network, Share2, Search, ArrowRight, HelpCircle, Sparkles } from "lucide-react"
import { useUserStore } from "@/stores/user-store"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { useSchemaStore, serializeAttributes } from "@/stores/schema-store"
import { isMocksEnabled } from "@/lib/mock-data"
import { isMocksEnabled, MOCK_DOMAINS } from "@/lib/mock-data"
import { getSchemaDomains } from "@/lib/graph-api"
import { filterSchemasByDomain, listSchemaDomains } from "@/lib/schema-domains"
import { SMALL_SCHEMAS, SMALL_EDGES } from "./mock-small"
import type { SchemaNode, SchemaEdge, SchemaAttribute } from "@/lib/schema-types"

// The set of hidden domain keys, persisted in localStorage and exposed as an
// external store (same pattern as the graph pane's view mode): the server
// snapshot is always "nothing hidden" so SSR and the first client render agree,
// then the stored set takes over without a setState-in-effect.
const DISABLED_DOMAINS_STORAGE_KEY = "ontology:disabled-domains"
const NO_DISABLED_DOMAINS: ReadonlySet<string> = new Set()
const disabledDomainsListeners = new Set<() => void>()
let disabledDomainsCache: { raw: string | null; set: ReadonlySet<string> } = {
raw: null,
set: NO_DISABLED_DOMAINS,
}
function subscribeDisabledDomains(cb: () => void) {
disabledDomainsListeners.add(cb)
window.addEventListener("storage", cb)
return () => {
disabledDomainsListeners.delete(cb)
window.removeEventListener("storage", cb)
}
}
function readDisabledDomains(): ReadonlySet<string> {
let raw: string | null = null
try {
raw = window.localStorage.getItem(DISABLED_DOMAINS_STORAGE_KEY)
} catch {
// storage unavailable — fall back to whatever this session last chose
return disabledDomainsCache.set
}
// Same raw string → same Set instance, as useSyncExternalStore requires.
if (raw === disabledDomainsCache.raw) return disabledDomainsCache.set
let set: ReadonlySet<string> = NO_DISABLED_DOMAINS
try {
const parsed: unknown = raw ? JSON.parse(raw) : []
if (Array.isArray(parsed)) {
set = new Set(parsed.filter((k): k is string => typeof k === "string").map((k) => k.toLowerCase()))
}
} catch {
// malformed entry — treat as nothing hidden
}
disabledDomainsCache = { raw, set }
return set
}
function writeDisabledDomains(next: ReadonlySet<string>) {
try {
if (next.size === 0) window.localStorage.removeItem(DISABLED_DOMAINS_STORAGE_KEY)
else window.localStorage.setItem(DISABLED_DOMAINS_STORAGE_KEY, JSON.stringify(Array.from(next).sort()))
} catch {
// storage unavailable (private mode) — keep the choice for this session only
disabledDomainsCache = { raw: disabledDomainsCache.raw, set: next }
}
for (const cb of disabledDomainsListeners) cb()
}

export default function OntologyPage() {
const router = useRouter()
const isAdmin = useUserStore((s) => s.isAdmin)
Expand All @@ -38,40 +93,94 @@ export default function OntologyPage() {
const [showHelp, setShowHelp] = useState(false)
// When true, the AI ontology-editor panel takes over the right-panel slot.
const [showAgent, setShowAgent] = useState(false)
// Authoritative domain list from /v2/schema/domains (MOCK_DOMAINS in mock
// mode); unioned with the domains found on the loaded schemas below.
const [apiDomains, setApiDomains] = useState<string[]>([])
const disabledDomains = useSyncExternalStore(
subscribeDisabledDomains,
readDisabledDomains,
() => NO_DISABLED_DOMAINS
)

useEffect(() => {
if (isMocksEnabled()) {
store.setSchemas(SMALL_SCHEMAS)
store.setEdges(SMALL_EDGES)
setApiDomains(MOCK_DOMAINS.domains)
} else {
store.fetchAll()
getSchemaDomains()
.then((r) => setApiDomains(r.domains ?? []))
.catch(() => {
// the schemas' own domains still populate the filter
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])

const domainOptions = useMemo(
() => listSchemaDomains(store.schemas, apiDomains),
[store.schemas, apiDomains]
)

// Pure domain filter applied before the data reaches either graph view and
// the sidebar lists. The store's schemas/edges are never mutated; the editing
// panels keep receiving the full store (parent pickers etc. need every type).
const filtered = useMemo(
() => filterSchemasByDomain(store.schemas, store.edges, disabledDomains),
[store.schemas, store.edges, disabledDomains]
)

// A selection that the filter just removed would point at nothing on the canvas.
useEffect(() => {
if (selectedId && !filtered.schemas.some((s) => s.ref_id === selectedId)) {
setSelectedId(null)
}
}, [filtered.schemas, selectedId])
useEffect(() => {
if (selectedEdgeType && !filtered.edges.some((e) => e.edge_type === selectedEdgeType)) {
setSelectedEdgeType(null)
}
}, [filtered.edges, selectedEdgeType])

const handleToggleDomain = useCallback(
(key: string) => {
const next = new Set(disabledDomains)
if (next.has(key)) next.delete(key)
else next.add(key)
writeDisabledDomains(next)
},
[disabledDomains]
)
const handleAllDomains = useCallback(() => writeDisabledDomains(NO_DISABLED_DOMAINS), [])
const handleNoDomains = useCallback(
() => writeDisabledDomains(new Set(domainOptions.map((d) => d.key))),
[domainOptions]
)

const selected = store.schemas.find((s) => s.ref_id === selectedId) ?? null

// Filter by type name, then sort alphabetically (by first letter).
const visibleSchemas = useMemo(() => {
const q = search.trim().toLowerCase()
return store.schemas
return filtered.schemas
.filter((s) => !q || s.type.toLowerCase().includes(q))
.sort((a, b) => a.type.localeCompare(b.type))
}, [store.schemas, search])
}, [filtered.schemas, search])

// Deduplicate edges by edge_type (exclude CHILD_OF), filter by edgeSearch, sort alphabetically
const visibleEdgeTypes = useMemo(() => {
const q = edgeSearch.trim().toLowerCase()
const countMap = new Map<string, number>()
for (const e of store.edges) {
for (const e of filtered.edges) {
if (e.edge_type === "CHILD_OF") continue
countMap.set(e.edge_type, (countMap.get(e.edge_type) ?? 0) + 1)
}
return Array.from(countMap.entries())
.filter(([edgeType]) => !q || edgeType.toLowerCase().includes(q))
.sort(([a], [b]) => a.localeCompare(b))
.map(([edgeType, count]) => ({ edgeType, count }))
}, [store.edges, edgeSearch])
}, [filtered.edges, edgeSearch])

// Stable so the memoized OntologyGraph doesn't re-render on unrelated page updates.
const handleClearSelection = useCallback(() => setSelectedId(null), [])
Expand Down Expand Up @@ -384,6 +493,15 @@ export default function OntologyPage() {
</div>
</div>

{/* Domain filter (applies to both graph views and the lists above) */}
<DomainFilter
domains={domainOptions}
disabled={disabledDomains}
onToggle={handleToggleDomain}
onAll={handleAllDomains}
onNone={handleNoDomains}
/>

{/* List */}
<div className="relative z-10 flex-1 overflow-y-auto p-2 space-y-1">
{sidebarTab === "nodes" ? (
Expand Down Expand Up @@ -458,17 +576,17 @@ export default function OntologyPage() {
<div className="flex-1 min-w-0">
{graphView === "network" ? (
<OntologyNeo4jGraph
schemas={store.schemas}
edges={store.edges}
schemas={filtered.schemas}
edges={filtered.edges}
selectedId={selectedId}
onSelect={setSelectedId}
onClear={handleClearSelection}
selectedEdgeType={selectedEdgeType}
/>
) : (
<OntologyGraph
schemas={store.schemas}
edges={store.edges}
schemas={filtered.schemas}
edges={filtered.edges}
selectedId={selectedId}
onSelect={setSelectedId}
onClear={handleClearSelection}
Expand Down
Loading
Loading