diff --git a/src/components/feed/feed-view.tsx b/src/components/feed/feed-view.tsx index d7f1acd..b923d6e 100644 --- a/src/components/feed/feed-view.tsx +++ b/src/components/feed/feed-view.tsx @@ -8,6 +8,7 @@ import { useAppStore } from "@/stores/app-store" import { useSchemaStore } from "@/stores/schema-store" import { isMocksEnabled, MOCK_NODES, MOCK_EDGES } from "@/lib/mock-data" import { getLatestNodes } from "@/lib/graph-api" +import { schemaTypeComparator } from "@/lib/node-schema-utils" import { FeedCard } from "./feed-card" import { HotTakes } from "./hot-takes" import { cn } from "@/lib/utils" @@ -63,14 +64,17 @@ export function FeedView() { [nodes, searchTerm] ) + // Dropdown order follows the ontology (height, then centrality, then name), + // not how many nodes of each type happen to be loaded; counts stay as hints. const typeCounts = useMemo(() => { const counts = new Map() for (const n of listNodes) { const t = n.node_type ?? "Unknown" counts.set(t, (counts.get(t) ?? 0) + 1) } - return [...counts.entries()].sort((a, b) => b[1] - a[1]) - }, [listNodes]) + const byRank = schemaTypeComparator(schemas) + return [...counts.entries()].sort(([a], [b]) => byRank(a, b)) + }, [listNodes, schemas]) const filtered = useMemo( () => diff --git a/src/lib/__tests__/node-schema-utils.test.ts b/src/lib/__tests__/node-schema-utils.test.ts index 9819df0..a613906 100644 --- a/src/lib/__tests__/node-schema-utils.test.ts +++ b/src/lib/__tests__/node-schema-utils.test.ts @@ -5,6 +5,7 @@ import { categorizeField, fieldsForSchema, OPTIONAL_GROUP_ORDER, + schemaTypeComparator, } from "@/lib/node-schema-utils" import type { SchemaNode } from "@/lib/schema-types" @@ -104,3 +105,52 @@ describe("fieldsForSchema — hidden attributes", () => { expect(keys).not.toContain("weight") }) }) + +describe("schemaTypeComparator", () => { + const rank = (type: string, height?: number, centrality?: number) => + ({ type, height, centrality }) as unknown as SchemaNode + const schemas = [ + rank("Thing", 0, 5), + rank("Person", 1, 6), + rank("Organization", 1, 3), + rank("Topic", 1, 3), + rank("Tweet", 2, 4), + ] + const order = (types: string[], s: SchemaNode[] = schemas) => + [...types].sort(schemaTypeComparator(s)) + + it("orders by height, then centrality, then name", () => { + expect(order(["Tweet", "Topic", "Organization", "Person", "Thing"])).toEqual([ + "Thing", + "Person", + "Organization", + "Topic", + "Tweet", + ]) + }) + + it("keeps the lower height first even when it is less central", () => { + expect(order(["Tweet", "Organization"])).toEqual(["Organization", "Tweet"]) + }) + + it("puts types with no schema or no height last, A→Z", () => { + expect(order(["Unknown", "Clip", "Person"], [...schemas, rank("Clip")])).toEqual([ + "Person", + "Clip", + "Unknown", + ]) + }) + + it("is plain alphabetical when there is no ranking data", () => { + const unranked = [rank("Tweet"), rank("Person"), rank("Organization")] + expect(order(["Tweet", "Person", "Organization"], unranked)).toEqual([ + "Organization", + "Person", + "Tweet", + ]) + }) + + it("matches type names case-insensitively", () => { + expect(order(["person", "thing"])).toEqual(["thing", "person"]) + }) +}) diff --git a/src/lib/__tests__/schema-store.test.ts b/src/lib/__tests__/schema-store.test.ts index 9530c48..3d5062f 100644 --- a/src/lib/__tests__/schema-store.test.ts +++ b/src/lib/__tests__/schema-store.test.ts @@ -160,6 +160,42 @@ describe("schema-store – fetchAll inherited_attributes", () => { }) }) +describe("schema-store – fetchAll height and centrality", () => { + beforeEach(() => { + vi.clearAllMocks() + useSchemaStore.setState({ schemas: [], edges: [], loading: false }) + }) + + it("maps height and centrality from the API", async () => { + mockGet.mockResolvedValueOnce({ + schemas: [ + { ref_id: "p-1", type: "Person", parent: "Thing", height: 1, centrality: 6 }, + { ref_id: "t-1", type: "Tweet", parent: "Content", height: 2, centrality: 4 }, + ], + edges: [], + }) + + await useSchemaStore.getState().fetchAll() + + const byType = Object.fromEntries(useSchemaStore.getState().schemas.map((s) => [s.type, s])) + expect(byType.Person).toMatchObject({ height: 1, centrality: 6 }) + expect(byType.Tweet).toMatchObject({ height: 2, centrality: 4 }) + }) + + it("leaves them undefined when the API does not send them", async () => { + mockGet.mockResolvedValueOnce({ + schemas: [{ ref_id: "p-1", type: "Person", parent: "Thing" }], + edges: [], + }) + + await useSchemaStore.getState().fetchAll() + + const person = useSchemaStore.getState().schemas[0] + expect(person.height).toBeUndefined() + expect(person.centrality).toBeUndefined() + }) +}) + describe("schema-store – addSchema", () => { beforeEach(() => { vi.clearAllMocks() @@ -195,3 +231,30 @@ describe("schema-store – addSchema", () => { await expect(store.addSchema(makeSchema({ ref_id: "new-2" }))).rejects.toThrow("Failed to save schema") }) }) + +describe("schema-store – v2 endpoints", () => { + beforeEach(() => { + vi.clearAllMocks() + useSchemaStore.setState({ schemas: [makeSchema()], edges: [], loading: false }) + }) + + it("fetches the ontology from /v2/schema", async () => { + mockGet.mockResolvedValueOnce({ schemas: [], edges: [] }) + await useSchemaStore.getState().fetchAll() + expect(mockGet).toHaveBeenCalledWith("/v2/schema") + }) + + it("sends type and relationship writes to /v2/schema", async () => { + mockPut.mockResolvedValueOnce({}).mockResolvedValueOnce({}) + mockPost.mockResolvedValueOnce({}).mockResolvedValueOnce({}) + const store = useSchemaStore.getState() + + await store.updateSchema(makeSchema()) + await store.addSchema(makeSchema({ ref_id: "new-3", type: "NewType3" })) + await store.addEdge({ ref_id: "e-1", source: "a", target: "b", source_type: "A", target_type: "B", edge_type: "LINKS" }) + await store.updateEdge({ ref_id: "e-1", source: "a", target: "b", edge_type: "LINKS" }) + + expect(mockPut.mock.calls.map(([path]) => path)).toEqual(["/v2/schema/test-1", "/v2/schema/edge/e-1"]) + expect(mockPost.mock.calls.map(([path]) => path)).toEqual(["/v2/schema", "/v2/schema/edge"]) + }) +}) diff --git a/src/lib/node-schema-utils.ts b/src/lib/node-schema-utils.ts index c19efe7..5e3c697 100644 --- a/src/lib/node-schema-utils.ts +++ b/src/lib/node-schema-utils.ts @@ -49,6 +49,25 @@ export function fieldsForSchema(schema: SchemaNode): SchemaAttribute[] { // of a flat dump. Required ("core") fields are rendered separately, up front. export type FieldGroup = "content" | "meta" | "signal" +// Orders node type names for pickers: lower height first (closer to the +// ontology root), then higher centrality (more child + connected types), then +// A→Z. Types with no schema or no height (e.g. "Unknown", or a backend that +// predates ranking) sort after ranked ones — so with no ranking data at all +// this is plain alphabetical. Type lookup is case-insensitive. +export function schemaTypeComparator(schemas: SchemaNode[]): (a: string, b: string) => number { + const byType = new Map(schemas.map((s) => [s.type.toLowerCase(), s])) + return (a, b) => { + const sa = byType.get(a.toLowerCase()) + const sb = byType.get(b.toLowerCase()) + const heightA = sa?.height ?? Infinity + const heightB = sb?.height ?? Infinity + if (heightA !== heightB) return heightA - heightB + const centralityDiff = (sb?.centrality ?? 0) - (sa?.centrality ?? 0) + if (centralityDiff !== 0) return centralityDiff + return a.localeCompare(b) + } +} + export const OPTIONAL_GROUP_ORDER: FieldGroup[] = ["content", "meta", "signal"] export const OPTIONAL_GROUP_LABELS: Record = { diff --git a/src/lib/schema-types.ts b/src/lib/schema-types.ts index fec04db..d373741 100644 --- a/src/lib/schema-types.ts +++ b/src/lib/schema-types.ts @@ -27,6 +27,14 @@ export interface SchemaNode { icon?: string secondary_color?: string paid_properties?: string[] + // Ordering signals computed backend-side from the ontology alone (never from + // instance counts). `height` is the position in the CHILD_OF hierarchy: 0 for + // a root, 1 + the highest parent otherwise. `centrality` is the number of + // direct child types + distinct other types linked by a schema edge. Sort by + // height ascending, then centrality descending. Absent on mock fixtures and + // on backends that predate them. + height?: number + centrality?: number } export interface SchemaEdge { diff --git a/src/stores/schema-store.ts b/src/stores/schema-store.ts index c32a32e..5fa730e 100644 --- a/src/stores/schema-store.ts +++ b/src/stores/schema-store.ts @@ -62,7 +62,7 @@ export const useSchemaStore = create((set) => ({ if (isMocksEnabled()) return try { - await api.put(`/schema/${updated.ref_id}`, { + await api.put(`/v2/schema/${updated.ref_id}`, { type: updated.type, parent: updated.parent, primary_color: updated.color, @@ -93,7 +93,7 @@ export const useSchemaStore = create((set) => ({ if (isMocksEnabled()) return schema.ref_id try { - const res = await api.post<{ ref_id?: string }>("/schema", { + const res = await api.post<{ ref_id?: string }>("/v2/schema", { type: schema.type, parent: schema.parent, primary_color: schema.color, @@ -129,7 +129,7 @@ export const useSchemaStore = create((set) => ({ if (isMocksEnabled()) return try { - await api.delete(`/schema/${refId}`) + await api.delete(`/v2/schema/${refId}`) } catch (err) { console.error("Failed to delete schema:", err) // Rollback @@ -145,7 +145,7 @@ export const useSchemaStore = create((set) => ({ try { // The backend keys edge schemas off type NAMES, not ref_ids. - const res = await api.post<{ ref_id?: string }>("/schema/edge", { + const res = await api.post<{ ref_id?: string }>("/v2/schema/edge", { source: edge.source_type ?? edge.source, target: edge.target_type ?? edge.target, edge_type: edge.edge_type, @@ -182,7 +182,7 @@ export const useSchemaStore = create((set) => ({ if (isMocksEnabled()) return try { - await api.put(`/schema/edge/${edge.ref_id}`, { + await api.put(`/v2/schema/edge/${edge.ref_id}`, { edge_type: edge.edge_type, attributes: edge.attributes ?? {}, }) @@ -204,7 +204,7 @@ export const useSchemaStore = create((set) => ({ if (isMocksEnabled()) return try { - await api.delete(`/schema/edge/${refId}`) + await api.delete(`/v2/schema/edge/${refId}`) } catch (err) { console.error("Failed to delete relationship:", err) // Rollback @@ -231,9 +231,11 @@ export const useSchemaStore = create((set) => ({ attributes?: Record inherited_attributes?: Record paid_properties?: string[] + height?: number + centrality?: number }> edges: SchemaEdge[] - }>("/schema/all") + }>("/v2/schema") const schemas: SchemaNode[] = (res.schemas ?? []).map((s) => ({ ref_id: s.ref_id, @@ -250,6 +252,8 @@ export const useSchemaStore = create((set) => ({ attributes: parseAttributes(s.attributes), inherited_attributes: parseAttributes(s.inherited_attributes as Record | undefined), paid_properties: Array.isArray(s.paid_properties) ? (s.paid_properties as string[]) : undefined, + height: s.height, + centrality: s.centrality, })) set({ schemas, edges: res.edges ?? [] })