diff --git a/src/app/domains/domain-panel.tsx b/src/app/domains/domain-panel.tsx new file mode 100644 index 0000000..e4fef56 --- /dev/null +++ b/src/app/domains/domain-panel.tsx @@ -0,0 +1,296 @@ +"use client" + +import { useMemo, useState } from "react" +import { X, Trash2, Plus, EyeOff, Eye, Info } 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 { MultiSelectCustom } from "@/components/ui/multi-select-custom" +import { MAX_LENGTHS } from "@/lib/input-limits" +import type { SchemaNode } from "@/app/ontology/page" + +export interface DomainRow { + /** Lowercased domain identifier (the canonical key). */ + key: string + /** Display casing (from a member schema, or capitalized key). */ + label: string + /** Node types whose `domain` resolves to this domain. */ + members: SchemaNode[] + hidden: boolean +} + +interface DomainPanelProps { + domain: DomainRow + /** All schema types — used to pick which to add to this domain. */ + allTypes: SchemaNode[] + onRename: (newName: string) => void + onAddTypes: (typeNames: string[]) => void + onRemoveType: (typeName: string) => void + onToggleHidden: (hidden: boolean) => void + onDelete: () => void + onClose: () => void + busy?: boolean + error?: string +} + +/** + * Manage a single domain (a category grouping many node types). Rename cascades + * the `domain` property across member types; add/remove reassigns a type's + * domain. All writes go through the schema API, then a background relabel + * catches up existing nodes — surfaced via the note below. + */ +export function DomainPanel({ + domain, + allTypes, + onRename, + onAddTypes, + onRemoveType, + onToggleHidden, + onDelete, + onClose, + busy, + error, +}: DomainPanelProps) { + const [renameValue, setRenameValue] = useState(domain.label) + const [confirmingRename, setConfirmingRename] = useState(false) + const [toAdd, setToAdd] = useState([]) + + // Reset local edit state when switching domains. + const renameDirty = + renameValue.trim().length > 0 && + renameValue.trim().toLowerCase() !== domain.key + + const memberKeys = useMemo( + () => new Set(domain.members.map((m) => m.type)), + [domain.members] + ) + + const addableOptions = useMemo( + () => + allTypes + .filter((s) => s.type && s.type !== "Thing" && !memberKeys.has(s.type)) + .sort((a, b) => a.type.localeCompare(b.type)) + .map((s) => ({ + value: s.type, + label: s.type, + hint: s.domain ? s.domain.toLowerCase() : undefined, + })), + [allTypes, memberKeys] + ) + + const startRename = () => { + if (renameDirty) setConfirmingRename(true) + } + const confirmRename = () => { + onRename(renameValue.trim()) + setConfirmingRename(false) + } + + const commitAdd = () => { + if (toAdd.length === 0) return + onAddTypes(toAdd) + setToAdd([]) + } + + return ( +
+ {/* Header */} +
+
+

{domain.label}

+ + {domain.key} + + {domain.hidden && ( + + + hidden + + )} +
+ +
+ +
+ {/* Rename */} +
+ + {confirmingRename ? ( +
+

+ Rename {domain.key} →{" "} + {renameValue.trim().toLowerCase()}? + This updates {domain.members.length} member type + {domain.members.length === 1 ? "" : "s"} and relabels their + existing nodes in the background. +

+
+ + +
+
+ ) : ( +
+ setRenameValue(e.target.value)} + maxLength={MAX_LENGTHS.SCHEMA_TYPE_NAME} + className="h-8 text-sm bg-muted/50 border-border/50" + /> + +
+ )} +
+ + {/* Visibility */} +
+
+ {domain.hidden ? ( + + ) : ( + + )} + Hidden from search +
+ onToggleHidden(!!c)} + disabled={busy} + className="scale-90" + /> +
+ + + + {/* Member types */} +
+
+ + + {domain.members.length} + +
+ + {domain.members.length === 0 ? ( +

+ No node types in this domain. +

+ ) : ( +
+ {domain.members.map((m) => ( +
+
+
+

{m.type}

+ {m.parent && ( +

+ ↳ {m.parent} +

+ )} +
+ +
+ ))} +
+ )} + + {/* Add types */} +
+ + +
+
+ + {/* Relabel note */} +
+ +

+ Changes apply to the domains list and newly-created nodes immediately. + Existing nodes are relabeled in the background. +

+
+
+ + {/* Footer */} +
+ {error &&

{error}

} + {domain.members.length > 0 ? ( +

+ Remove all member types to delete this domain. +

+ ) : ( + + )} +
+
+ ) +} diff --git a/src/app/domains/page.tsx b/src/app/domains/page.tsx new file mode 100644 index 0000000..09fcd40 --- /dev/null +++ b/src/app/domains/page.tsx @@ -0,0 +1,422 @@ +"use client" + +import { useCallback, useEffect, useMemo, useState } from "react" +import { useRouter } from "next/navigation" +import { ArrowLeft, Plus, Search, Boxes, EyeOff } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { MultiSelectCustom } from "@/components/ui/multi-select-custom" +import { DomainPanel, type DomainRow } from "./domain-panel" +import type { SchemaNode } from "@/app/ontology/page" +import { useSchemaStore } from "@/stores/schema-store" +import { useUserStore } from "@/stores/user-store" +import { useAppStore } from "@/stores/app-store" +import { isMocksEnabled, MOCK_DOMAINS } from "@/lib/mock-data" +import { SMALL_SCHEMAS, SMALL_EDGES } from "@/app/ontology/mock-small" +import { + getSchemaDomains, + updateHiddenLists, + relabelDomain, + 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() +} + +function capitalize(s: string): string { + return s ? s.charAt(0).toUpperCase() + s.slice(1) : s +} + +export default function DomainsPage() { + const router = useRouter() + const isAdmin = useUserStore((s) => s.isAdmin) + const isAuthenticated = useUserStore((s) => s.isAuthenticated) + const store = useSchemaStore() + const { graphName, graphDescription } = useAppStore() + + const [domainsInfo, setDomainsInfo] = useState(null) + const [loadingDomains, setLoadingDomains] = useState(true) + const [selectedKey, setSelectedKey] = useState(null) + const [search, setSearch] = useState("") + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + + // Create flow + const [creating, setCreating] = useState(false) + const [createName, setCreateName] = useState("") + const [createTypes, setCreateTypes] = useState([]) + + useEffect(() => { + if (isAuthenticated && !isAdmin) router.replace("/") + }, [isAdmin, isAuthenticated, router]) + + const reloadDomains = useCallback(async () => { + setLoadingDomains(true) + try { + setDomainsInfo(isMocksEnabled() ? MOCK_DOMAINS : await getSchemaDomains()) + } catch { + // keep last good list + } finally { + setLoadingDomains(false) + } + }, []) + + useEffect(() => { + if (isMocksEnabled()) { + store.setSchemas(SMALL_SCHEMAS) + store.setEdges(SMALL_EDGES) + } else { + store.fetchAll() + } + reloadDomains() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // Group node types by their `domain`, merged with the authoritative + // /v2/schema/domains list (so empty/known domains still appear). + const rows = useMemo(() => { + const hidden = new Set( + (domainsInfo?.hidden_domains ?? []).map((d) => d.toLowerCase()) + ) + const membersByKey = new Map() + const labelByKey = new Map() + + for (const s of store.schemas) { + if (!s.type || s.type === "Thing") continue + const key = domainKeyOf(s) + const arr = membersByKey.get(key) ?? [] + arr.push(s) + membersByKey.set(key, arr) + if (!labelByKey.has(key) && s.domain) labelByKey.set(key, s.domain) + } + + const keys = new Set(membersByKey.keys()) + for (const d of domainsInfo?.domains ?? []) keys.add(d.toLowerCase()) + + return Array.from(keys) + .map((key) => ({ + key, + label: labelByKey.get(key) ?? capitalize(key), + members: (membersByKey.get(key) ?? []).sort((a, b) => + a.type.localeCompare(b.type) + ), + hidden: hidden.has(key), + })) + .sort((a, b) => a.label.localeCompare(b.label)) + }, [store.schemas, domainsInfo]) + + const visibleRows = useMemo(() => { + const q = search.trim().toLowerCase() + return q ? rows.filter((r) => r.key.includes(q) || r.label.toLowerCase().includes(q)) : rows + }, [rows, search]) + + const selectedRow = useMemo( + () => rows.find((r) => r.key === selectedKey) ?? null, + [rows, selectedKey] + ) + + // --- Write helpers ------------------------------------------------------ + + // Reassign a set of types to `domainValue` (PUT /schema each), then relabel + // existing nodes in the background. + const assignTypesToDomain = useCallback( + async (typeNames: string[], domainValue: string) => { + const targets = store.schemas.filter((s) => typeNames.includes(s.type)) + if (targets.length === 0) return + setBusy(true) + setError(null) + try { + for (const s of targets) { + await store.updateSchema({ ...s, domain: domainValue }) + } + await relabelDomain(targets.map((s) => s.type)).catch(() => {}) + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to update domain") + } finally { + setBusy(false) + } + }, + [store] + ) + + const handleRename = useCallback( + async (row: DomainRow, newName: string) => { + await assignTypesToDomain( + row.members.map((m) => m.type), + newName + ) + setSelectedKey(newName.trim().toLowerCase()) + reloadDomains() + }, + [assignTypesToDomain, reloadDomains] + ) + + const handleAddTypes = useCallback( + async (row: DomainRow, typeNames: string[]) => { + await assignTypesToDomain(typeNames, row.label) + reloadDomains() + }, + [assignTypesToDomain, reloadDomains] + ) + + const handleRemoveType = useCallback( + async (typeName: string) => { + await assignTypesToDomain([typeName], DEFAULT_DOMAIN) + reloadDomains() + }, + [assignTypesToDomain, reloadDomains] + ) + + const handleToggleHidden = useCallback( + async (row: DomainRow, hidden: boolean) => { + const current = new Set( + (domainsInfo?.hidden_domains ?? []).map((d) => d.toLowerCase()) + ) + if (hidden) current.add(row.key) + else current.delete(row.key) + const next = Array.from(current).sort() + setBusy(true) + setError(null) + try { + if (!isMocksEnabled()) { + await updateHiddenLists(graphName, graphDescription, undefined, next) + } + setDomainsInfo((prev) => + prev ? { ...prev, hidden_domains: next } : prev + ) + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to update visibility") + } finally { + setBusy(false) + } + }, + [domainsInfo, graphName, graphDescription] + ) + + const handleDelete = useCallback( + async (row: DomainRow) => { + // Empty domains are derived away on reload; just clean up a stale hidden entry. + if (row.hidden) await handleToggleHidden(row, false) + setSelectedKey(null) + reloadDomains() + }, + [handleToggleHidden, reloadDomains] + ) + + const submitCreate = useCallback(async () => { + const name = createName.trim() + if (!name || createTypes.length === 0) return + await assignTypesToDomain(createTypes, name) + setCreating(false) + setCreateName("") + setCreateTypes([]) + setSelectedKey(name.toLowerCase()) + reloadDomains() + }, [createName, createTypes, assignTypesToDomain, reloadDomains]) + + const openCreate = useCallback(() => { + setSelectedKey(null) + setCreating(true) + setCreateName("") + setCreateTypes([]) + setError(null) + }, []) + + const allTypeOptions = useMemo( + () => + store.schemas + .filter((s) => s.type && s.type !== "Thing") + .sort((a, b) => a.type.localeCompare(b.type)) + .map((s) => ({ + value: s.type, + label: s.type, + hint: s.domain ? s.domain.toLowerCase() : undefined, + })), + [store.schemas] + ) + + if (isAuthenticated && !isAdmin) return null + + const loading = loadingDomains && store.loading + + return ( +
+ {/* Left: Domain list */} +
+
+ +

+ Domains +

+ +
+
+
+ + setSearch(e.target.value)} + placeholder="Search domains..." + className="h-8 pl-8 text-sm" + /> +
+
+
+ {loading && visibleRows.length === 0 && ( +

Loading…

+ )} + {!loading && visibleRows.length === 0 && ( +

+ {search ? `No domains match “${search}”` : "No domains yet."} +

+ )} + {visibleRows.map((row) => ( + + ))} +
+
+ + {/* Right: panel / create / empty state */} +
+ {creating ? ( +
+
+
+

New domain

+

+ Name the domain and assign at least one node type — a domain only + exists while a type belongs to it. +

+
+
+ + setCreateName(e.target.value)} + placeholder="e.g. Social" + maxLength={MAX_LENGTHS.SCHEMA_TYPE_NAME} + className="h-8 text-sm bg-muted/50 border-border/50" + /> +
+
+ + +
+ {error &&

{error}

} +
+ + +
+
+
+ ) : selectedRow ? ( +
+ handleRename(selectedRow, name)} + onAddTypes={(types) => handleAddTypes(selectedRow, types)} + onRemoveType={handleRemoveType} + onToggleHidden={(hidden) => handleToggleHidden(selectedRow, hidden)} + onDelete={() => handleDelete(selectedRow)} + onClose={() => setSelectedKey(null)} + busy={busy} + error={error ?? undefined} + /> +
+ ) : ( +
+
+ +
+
+

+ Domains organize your graph +

+

+ A domain is a category that groups many node types (e.g. Content ⊇ + TwitterAccount, Tweet, Topic). Select a domain to manage its member + types and visibility, or create a new one. +

+
+ +
+ )} +
+
+ ) +} diff --git a/src/app/ontology/page.tsx b/src/app/ontology/page.tsx index 85aa794..987911c 100644 --- a/src/app/ontology/page.tsx +++ b/src/app/ontology/page.tsx @@ -31,6 +31,11 @@ export interface SchemaNode { parent: string color: string node_key: string + // The search domain this schema belongs to (lowercased). Domains are derived + // backend-side from `DISTINCT toLower(s.domain)`; a root type only registers as + // its own domain when this is set to its name. Omitted → backend defaults to + // "entity". Set explicitly by the Domains editor; left unset by the ontology editor. + domain?: string attributes: SchemaAttribute[] inherited_attributes?: SchemaAttribute[] title_key?: string diff --git a/src/components/layout/toolkit.tsx b/src/components/layout/toolkit.tsx index e7115ab..b22d926 100644 --- a/src/components/layout/toolkit.tsx +++ b/src/components/layout/toolkit.tsx @@ -8,6 +8,7 @@ import { Settings, Zap, Network, + Boxes, BookMarked, ClipboardList, Heart, @@ -222,6 +223,11 @@ export function Toolkit({ ariaLabel="Ontology" onClick={() => router.push("/ontology")} /> + router.push("/domains")} + /> {[ { icon: Network, label: "Ontology", action: () => router.push("/ontology") }, + { icon: Boxes, label: "Domains", action: () => router.push("/domains") }, { icon: ClipboardList, label: `Reviews${pendingCount > 0 ? ` (${pendingCount})` : ""}`, diff --git a/src/lib/__tests__/domain-panel.test.tsx b/src/lib/__tests__/domain-panel.test.tsx new file mode 100644 index 0000000..33e494f --- /dev/null +++ b/src/lib/__tests__/domain-panel.test.tsx @@ -0,0 +1,101 @@ +import { describe, it, expect, vi } from "vitest" +import { render, screen, within } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { DomainPanel, type DomainRow } from "@/app/domains/domain-panel" +import type { SchemaNode } from "@/app/ontology/page" + +function schema(type: string, domain?: string): SchemaNode { + return { + ref_id: `s-${type}`, + type, + parent: "Thing", + domain, + color: "#6366f1", + node_key: "name", + attributes: [{ key: "name", type: "string", required: true }], + } +} + +const members = [schema("TwitterAccount", "Content"), schema("Tweet", "Content")] +const domain: DomainRow = { + key: "content", + label: "Content", + members, + hidden: false, +} + +const allTypes: SchemaNode[] = [ + ...members, + schema("Person", "Entity"), + schema("Repository", "CodeArtifact"), +] + +const defaultProps = { + domain, + allTypes, + onRename: vi.fn(), + onAddTypes: vi.fn(), + onRemoveType: vi.fn(), + onToggleHidden: vi.fn(), + onDelete: vi.fn(), + onClose: vi.fn(), +} + +describe("DomainPanel", () => { + it("renders the domain label, key, and member types", () => { + render() + expect(screen.getByDisplayValue("Content")).toBeTruthy() + expect(screen.getByText("content")).toBeTruthy() + expect(screen.getByText("TwitterAccount")).toBeTruthy() + expect(screen.getByText("Tweet")).toBeTruthy() + }) + + it("rename requires confirmation, then fires onRename with the new name", async () => { + const onRename = vi.fn() + render() + const input = screen.getByDisplayValue("Content") + await userEvent.clear(input) + await userEvent.type(input, "Social") + await userEvent.click(screen.getByRole("button", { name: "Rename" })) + // Confirmation step — not yet called + expect(onRename).not.toHaveBeenCalled() + await userEvent.click(screen.getByRole("button", { name: "Rename" })) + expect(onRename).toHaveBeenCalledWith("Social") + }) + + it("removing a member calls onRemoveType with the type name", async () => { + const onRemoveType = vi.fn() + render() + const row = screen.getByText("TwitterAccount").closest("div")!.parentElement! + const removeBtn = within(row).getByTitle(/Remove from domain/) + await userEvent.click(removeBtn) + expect(onRemoveType).toHaveBeenCalledWith("TwitterAccount") + }) + + it("toggling visibility calls onToggleHidden", async () => { + const onToggleHidden = vi.fn() + render() + await userEvent.click(screen.getByRole("switch")) + expect(onToggleHidden).toHaveBeenCalledWith(true) + }) + + it("delete is unavailable while the domain has members", () => { + render() + expect(screen.queryByText(/Delete empty domain/)).toBeNull() + expect(screen.getByText(/Remove all member types to delete/)).toBeTruthy() + }) + + it("delete is available once the domain is empty", async () => { + const onDelete = vi.fn() + render( + + ) + const btn = screen.getByRole("button", { name: /Delete empty domain/ }) + await userEvent.click(btn) + expect(onDelete).toHaveBeenCalled() + }) +}) diff --git a/src/lib/graph-api.ts b/src/lib/graph-api.ts index 39d0fdf..6c740d7 100644 --- a/src/lib/graph-api.ts +++ b/src/lib/graph-api.ts @@ -557,6 +557,23 @@ export async function updateHiddenLists( return api.post<{ status: string }>("/about", body, undefined, signal) } +// Re-apply canonical Domain_* labels to existing nodes of the given schema +// types after their `domain` was assigned/renamed. New nodes already get the +// right label at ingest; this catches up existing ones. Runs in a background +// job server-side and returns immediately. No-op in mock mode. +export async function relabelDomain( + types: string[], + signal?: AbortSignal +): Promise<{ status: string; types: string[] }> { + if (isMocksEnabled()) return { status: "relabeling", types } + return api.post<{ status: string; types: string[] }>( + "/v2/schema/relabel-domain", + { types }, + undefined, + signal + ) +} + // Free preflight — no payment required export async function checkTopicExists( name: string, diff --git a/src/stores/schema-store.ts b/src/stores/schema-store.ts index 90fa451..381c145 100644 --- a/src/stores/schema-store.ts +++ b/src/stores/schema-store.ts @@ -69,6 +69,9 @@ export const useSchemaStore = create((set) => ({ title_key: updated.title_key ?? null, description_key: updated.description_key ?? null, attributes: serializeAttributes(updated.attributes), + // Only sent when set (Domains editor) so the ontology editor's behavior + // — backend defaulting domain to "entity" — is left untouched. + ...(updated.domain ? { domain: updated.domain } : {}), }) } catch (err) { // Rollback optimistic update @@ -93,6 +96,9 @@ export const useSchemaStore = create((set) => ({ primary_color: schema.color, node_key: schema.node_key, attributes: serializeAttributes(schema.attributes), + // Only sent when set (Domains editor). Without it the backend defaults + // domain to "entity", so a root type would not register as its own domain. + ...(schema.domain ? { domain: schema.domain } : {}), }) // Update with real ref_id from server @@ -135,6 +141,7 @@ export const useSchemaStore = create((set) => ({ ref_id: string type: string parent?: string + domain?: string primary_color?: string secondary_color?: string node_key?: string @@ -153,6 +160,7 @@ export const useSchemaStore = create((set) => ({ ref_id: s.ref_id, type: s.type ?? "", parent: s.parent ?? "", + domain: s.domain, color: s.primary_color ?? "#64748b", secondary_color: s.secondary_color, node_key: s.node_key ?? "name",