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: 6 additions & 2 deletions src/components/feed/feed-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<string, number>()
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(
() =>
Expand Down
50 changes: 50 additions & 0 deletions src/lib/__tests__/node-schema-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
categorizeField,
fieldsForSchema,
OPTIONAL_GROUP_ORDER,
schemaTypeComparator,
} from "@/lib/node-schema-utils"
import type { SchemaNode } from "@/lib/schema-types"

Expand Down Expand Up @@ -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"])
})
})
63 changes: 63 additions & 0 deletions src/lib/__tests__/schema-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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"])
})
})
19 changes: 19 additions & 0 deletions src/lib/node-schema-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<FieldGroup, string> = {
Expand Down
8 changes: 8 additions & 0 deletions src/lib/schema-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
18 changes: 11 additions & 7 deletions src/stores/schema-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export const useSchemaStore = create<SchemaState>((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,
Expand Down Expand Up @@ -93,7 +93,7 @@ export const useSchemaStore = create<SchemaState>((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,
Expand Down Expand Up @@ -129,7 +129,7 @@ export const useSchemaStore = create<SchemaState>((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
Expand All @@ -145,7 +145,7 @@ export const useSchemaStore = create<SchemaState>((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,
Expand Down Expand Up @@ -182,7 +182,7 @@ export const useSchemaStore = create<SchemaState>((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 ?? {},
})
Expand All @@ -204,7 +204,7 @@ export const useSchemaStore = create<SchemaState>((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
Expand All @@ -231,9 +231,11 @@ export const useSchemaStore = create<SchemaState>((set) => ({
attributes?: Record<string, unknown>
inherited_attributes?: Record<string, unknown>
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,
Expand All @@ -250,6 +252,8 @@ export const useSchemaStore = create<SchemaState>((set) => ({
attributes: parseAttributes(s.attributes),
inherited_attributes: parseAttributes(s.inherited_attributes as Record<string, unknown> | 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 ?? [] })
Expand Down
Loading