From 3197e7d94e770fe42e697ae4466d7fd92108670f Mon Sep 17 00:00:00 2001 From: Rassl Date: Wed, 23 Sep 2026 04:09:34 +0400 Subject: [PATCH] feat: domain filter on the ontology page Adds a Domains section to the ontology sidebar: one checkbox per domain with its type count, plus All / None. Disabling a domain removes its schema types and every edge touching them from both the network and hierarchy views; Thing always stays as the root. A filtered-out selection is cleared. The disabled set persists in localStorage. Domain helpers (domainKeyOf, listSchemaDomains, filterSchemasByDomain) move to src/lib/schema-domains.ts and are shared with the domains admin page. The domain list is the union of /v2/schema/domains and the domains present on loaded schemas, so it still renders in mock mode. --- src/app/admin/domains/page.tsx | 8 +- src/app/admin/ontology/domain-filter.tsx | 95 ++++++++++++++++ src/app/admin/ontology/page.tsx | 138 +++++++++++++++++++++-- src/lib/__tests__/schema-domains.test.ts | 113 +++++++++++++++++++ src/lib/schema-domains.ts | 99 ++++++++++++++++ 5 files changed, 436 insertions(+), 17 deletions(-) create mode 100644 src/app/admin/ontology/domain-filter.tsx create mode 100644 src/lib/__tests__/schema-domains.test.ts create mode 100644 src/lib/schema-domains.ts diff --git a/src/app/admin/domains/page.tsx b/src/app/admin/domains/page.tsx index 90f8b9d..dc1133e 100644 --- a/src/app/admin/domains/page.tsx +++ b/src/app/admin/domains/page.tsx @@ -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 diff --git a/src/app/admin/ontology/domain-filter.tsx b/src/app/admin/ontology/domain-filter.tsx new file mode 100644 index 0000000..50a02b1 --- /dev/null +++ b/src/app/admin/ontology/domain-filter.tsx @@ -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 + 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 ( +
+ + + {open && ( +
+
+ + · + +
+
+ {domains.map((d) => { + const enabled = !disabled.has(d.key) + return ( + + ) + })} +
+
+ )} +
+ ) +} diff --git a/src/app/admin/ontology/page.tsx b/src/app/admin/ontology/page.tsx index 377b775..84b0831 100644 --- a/src/app/admin/ontology/page.tsx +++ b/src/app/admin/ontology/page.tsx @@ -1,6 +1,6 @@ "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" @@ -8,15 +8,70 @@ 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 = new Set() +const disabledDomainsListeners = new Set<() => void>() +let disabledDomainsCache: { raw: string | null; set: ReadonlySet } = { + 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 { + 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 = 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) { + 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) @@ -38,32 +93,86 @@ 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([]) + 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() - 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) } @@ -71,7 +180,7 @@ export default function OntologyPage() { .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), []) @@ -384,6 +493,15 @@ export default function OntologyPage() { + {/* Domain filter (applies to both graph views and the lists above) */} + + {/* List */}
{sidebarTab === "nodes" ? ( @@ -458,8 +576,8 @@ export default function OntologyPage() {
{graphView === "network" ? ( ) : ( ({ + ref_id, + type, + parent, + domain, + color: "#000000", + node_key: "name", + attributes: [{ key: "name", type: "string", required: true }], +}) + +const edge = ( + source: string, + target: string, + edge_type: string, + types?: { source_type: string; target_type: string } +): SchemaEdge => ({ + ref_id: `${source}-${edge_type}-${target}`, + source, + target, + edge_type, + ...types, +}) + +// Thing ─┬─ Person (Entity) +// ├─ Repository (CodeGraph) +// └─ Tweet (Content) +const SCHEMAS = [ + schema("thing", "Thing", ""), + schema("person", "Person", "Thing"), + schema("repo", "Repository", "Thing", "CodeGraph"), + schema("tweet", "Tweet", "Thing", "Content"), +] + +const EDGES = [ + edge("person", "thing", "CHILD_OF"), + edge("repo", "thing", "CHILD_OF"), + edge("tweet", "thing", "CHILD_OF"), + edge("tweet", "person", "AUTHORED_BY"), + edge("person", "repo", "CONTRIBUTES_TO"), +] + +describe("domainKeyOf", () => { + it("lowercases the domain and defaults to entity", () => { + expect(domainKeyOf(schema("a", "A", "Thing", "CodeGraph"))).toBe("codegraph") + expect(domainKeyOf(schema("a", "A", "Thing"))).toBe("entity") + expect(domainKeyOf(schema("a", "A", "Thing", ""))).toBe("entity") + }) +}) + +describe("listSchemaDomains", () => { + it("unions the API list with the schemas' domains, counting types per domain", () => { + const options = listSchemaDomains(SCHEMAS, ["content", "workflow"]) + expect(options).toEqual([ + { key: "codegraph", label: "CodeGraph", count: 1 }, + { key: "content", label: "Content", count: 1 }, + { key: "entity", label: "Entity", count: 1 }, + { key: "workflow", label: "Workflow", count: 0 }, + ]) + }) + + it("never counts the root type", () => { + expect(listSchemaDomains([schema("thing", "Thing", "")])).toEqual([]) + }) + + it("works without an API list (mock mode)", () => { + expect(listSchemaDomains(SCHEMAS).map((d) => d.key)).toEqual(["codegraph", "content", "entity"]) + }) +}) + +describe("filterSchemasByDomain", () => { + it("returns the same arrays when nothing is disabled", () => { + const out = filterSchemasByDomain(SCHEMAS, EDGES, new Set()) + expect(out.schemas).toBe(SCHEMAS) + expect(out.edges).toBe(EDGES) + }) + + it("drops types in a disabled domain and every edge touching them by ref_id", () => { + const out = filterSchemasByDomain(SCHEMAS, EDGES, new Set(["content"])) + expect(out.schemas.map((s) => s.type)).toEqual(["Thing", "Person", "Repository"]) + expect(out.edges.map((e) => e.ref_id)).toEqual([ + "person-CHILD_OF-thing", + "repo-CHILD_OF-thing", + "person-CONTRIBUTES_TO-repo", + ]) + }) + + it("also matches edges by source_type / target_type", () => { + const byName = [ + edge("x", "y", "MENTIONS", { source_type: "Tweet", target_type: "Person" }), + edge("x", "z", "MENTIONS", { source_type: "Person", target_type: "Repository" }), + ] + const out = filterSchemasByDomain(SCHEMAS, byName, new Set(["content"])) + expect(out.edges.map((e) => e.ref_id)).toEqual(["x-MENTIONS-z"]) + }) + + it("keeps Thing even when every domain is disabled", () => { + const out = filterSchemasByDomain(SCHEMAS, EDGES, new Set(["entity", "codegraph", "content"])) + expect(out.schemas.map((s) => s.type)).toEqual(["Thing"]) + expect(out.edges).toEqual([]) + }) + + it("does not mutate its inputs", () => { + const schemasCopy = SCHEMAS.map((s) => ({ ...s })) + const edgesCopy = EDGES.map((e) => ({ ...e })) + filterSchemasByDomain(SCHEMAS, EDGES, new Set(["entity"])) + expect(SCHEMAS).toEqual(schemasCopy) + expect(EDGES).toEqual(edgesCopy) + }) +}) diff --git a/src/lib/schema-domains.ts b/src/lib/schema-domains.ts new file mode 100644 index 0000000..e56766d --- /dev/null +++ b/src/lib/schema-domains.ts @@ -0,0 +1,99 @@ +// Domain grouping for schema (ontology) types, shared by the Domains editor +// and the ontology page's domain filter. + +import type { SchemaNode, SchemaEdge } from "./schema-types" + +export const DEFAULT_DOMAIN = "entity" + +/** The root of the CHILD_OF hierarchy; never filtered out by domain. */ +export const ROOT_TYPE = "Thing" + +/** The domain a schema type belongs to (lowercased; defaults to "entity"). */ +export function domainKeyOf(s: SchemaNode): string { + return (s.domain || DEFAULT_DOMAIN).toLowerCase() +} + +function capitalize(s: string): string { + return s ? s.charAt(0).toUpperCase() + s.slice(1) : s +} + +export interface SchemaDomainOption { + /** Canonical lowercased key. */ + key: string + /** Display label: the first schema's verbatim `domain`, else the capitalized key. */ + label: string + /** Number of (non-root) types in the domain. */ + count: number +} + +/** + * Domains to offer in a filter: the union of the authoritative + * `/v2/schema/domains` list and the domains present on the loaded schemas, so + * the list is complete in live mode and still sensible when only one side is + * available (mock mode, API failure). The root type is not counted. + */ +export function listSchemaDomains( + schemas: SchemaNode[], + apiDomains: readonly string[] = [] +): SchemaDomainOption[] { + const countByKey = new Map() + const labelByKey = new Map() + + for (const s of schemas) { + if (!s.type || s.type === ROOT_TYPE) continue + const key = domainKeyOf(s) + countByKey.set(key, (countByKey.get(key) ?? 0) + 1) + if (!labelByKey.has(key) && s.domain) labelByKey.set(key, s.domain) + } + + const keys = new Set(countByKey.keys()) + for (const d of apiDomains) { + const key = d.trim().toLowerCase() + if (key) keys.add(key) + } + + return Array.from(keys) + .map((key) => ({ + key, + label: labelByKey.get(key) ?? capitalize(key), + count: countByKey.get(key) ?? 0, + })) + .sort((a, b) => a.label.localeCompare(b.label)) +} + +/** + * Pure filter: drop every type whose domain is disabled (the root type always + * stays) and every edge with an endpoint in a dropped type — matched both by + * ref_id (`source`/`target`) and by type name (`source_type`/`target_type`). + * Returns the input arrays untouched when nothing is disabled so memoized + * consumers don't re-render. + */ +export function filterSchemasByDomain( + schemas: SchemaNode[], + edges: SchemaEdge[], + disabledDomains: ReadonlySet +): { schemas: SchemaNode[]; edges: SchemaEdge[] } { + if (disabledDomains.size === 0) return { schemas, edges } + + const removedRefs = new Set() + const removedTypes = new Set() + const kept: SchemaNode[] = [] + for (const s of schemas) { + if (s.type !== ROOT_TYPE && disabledDomains.has(domainKeyOf(s))) { + removedRefs.add(s.ref_id) + removedTypes.add(s.type) + } else { + kept.push(s) + } + } + if (removedRefs.size === 0) return { schemas, edges } + + const keptEdges = edges.filter( + (e) => + !removedRefs.has(e.source) && + !removedRefs.has(e.target) && + !(e.source_type && removedTypes.has(e.source_type)) && + !(e.target_type && removedTypes.has(e.target_type)) + ) + return { schemas: kept, edges: keptEdges } +}