From 2dc6e70474af1a781c9aade2faa4b58bb35397f6 Mon Sep 17 00:00:00 2001 From: Ben Younes <2910651+ousamabenyounes@users.noreply.github.com> Date: Wed, 9 Sep 2026 10:20:05 +0000 Subject: [PATCH] fix(ontology): show class hierarchy links Signed-off-by: Ben Younes <2910651+ousamabenyounes@users.noreply.github.com> --- web/src/classHierarchy.test.ts | 98 ++++++++++++++++++++++++++++++++++ web/src/i18n/en.ts | 5 +- web/src/i18n/zh.ts | 4 +- web/src/pages/Ontology.tsx | 20 ++++++- 4 files changed, 123 insertions(+), 4 deletions(-) create mode 100644 web/src/classHierarchy.test.ts diff --git a/web/src/classHierarchy.test.ts b/web/src/classHierarchy.test.ts new file mode 100644 index 000000000..6487de2d1 --- /dev/null +++ b/web/src/classHierarchy.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.hoisted(() => { + for (const name of ["WebGLRenderingContext", "WebGL2RenderingContext"]) { + Object.defineProperty(globalThis, name, { + configurable: true, + value: class WebGLRenderingContext {}, + }); + } +}); + +import { + Children, + isValidElement, + type ReactElement, + type ReactNode, +} from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { EntityTypeView } from "./api"; +import { en } from "./i18n/en"; +import { zh } from "./i18n/zh"; +import { ClassDefinition } from "./pages/Ontology"; + +const ROOT_ID = "root"; +const DIRECT_CHILD_ID = "direct-child"; +const OTHER_ID = "other"; +const GRANDCHILD_ID = "grandchild"; + +type TestElement = ReactElement<{ + children?: ReactNode; + onClick?: () => void; +}>; + +const elementChildren = (element: TestElement): TestElement[] => + Children.toArray(element.props.children).filter(isValidElement) as TestElement[]; + +const entityType = (id: string, parents: string[]): EntityTypeView => ({ + id, + key: id, + label: id, + color: "#000000", + shape: "circle", + builtin: false, + parents, + disjoint: [], + primary_parent: parents[0] ?? null, + description: "", + usage: 0, +}); + +describe("class hierarchy details", () => { + it("renders navigable parent and subclass controls", () => { + const selected = entityType(DIRECT_CHILD_ID, [ROOT_ID]); + const onSelectClass = vi.fn(); + const definition = ClassDefinition({ + cls: selected, + allTypes: [ + entityType(ROOT_ID, []), + selected, + entityType(GRANDCHILD_ID, [DIRECT_CHILD_ID]), + entityType(OTHER_ID, []), + ], + onSelectClass, + onNewSub: () => undefined, + }) as TestElement; + const html = renderToStaticMarkup(definition); + + expect(html).toContain(`>${ROOT_ID}`); + expect(html).toContain(`>${GRANDCHILD_ID}`); + expect(html).not.toContain(`>${OTHER_ID}`); + + const [parentField, subclassField] = elementChildren(definition); + const [parentLink] = elementChildren(elementChildren(parentField)[0]); + const [subclassLink] = elementChildren(elementChildren(subclassField)[0]); + parentLink.props.onClick?.(); + subclassLink.props.onClick?.(); + expect(onSelectClass.mock.calls).toEqual([[ROOT_ID], [GRANDCHILD_ID]]); + + const leafHtml = renderToStaticMarkup( + ClassDefinition({ + cls: entityType(OTHER_ID, []), + allTypes: [entityType(OTHER_ID, [])], + onSelectClass, + onNewSub: () => undefined, + }), + ); + expect(leafHtml).toContain(">None"); + }); + + it("explains that inheritance is shown on Definition in both locales", () => { + expect(en.ontology.subclasses).toBe("Subclasses"); + expect(en.ontology.noSubclasses).toBe("None"); + expect(zh.ontology.subclasses).toBe("子类"); + expect(zh.ontology.noSubclasses).toBe("无"); + expect(en.ontology.schemaNoRelationships).toContain("Definition"); + expect(zh.ontology.schemaNoRelationships).toContain("定义"); + }); +}); diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index c5f3e1233..54be6bf19 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1279,6 +1279,8 @@ export const en = { shapeColor: "Shape & color", parent: "Parent class", noParent: "(top level)", + subclasses: "Subclasses", + noSubclasses: "None", disjoint: "Cannot also be", disjointHint: "Classes nothing can belong to at the same time. A Person is not an Organisation. The consistency check uses this to find classes that can never have an instance.", @@ -1547,7 +1549,8 @@ export const en = { schemaBundle: (n: number) => `${n} relations`, schemaOutgoing: "From this class", schemaIncoming: "To this class", - schemaNoRelationships: "No relationships yet.", + schemaNoRelationships: + "This class takes part in no relationships. Its inheritance is shown under Definition.", schemaNoInstances: "No instances yet.", schemaConnectHint: "Connect using an existing relationship", schemaConnectPlaceholder: "Search relationships…", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 62f5d2ddb..f64d73a70 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1136,6 +1136,8 @@ export const zh: Strings = { shapeColor: "形状与颜色", parent: "父类", noParent: "(顶层)", + subclasses: "子类", + noSubclasses: "无", disjoint: "不可能同时是", disjointHint: "任何东西不可能同时属于的类。人不是组织。一致性检查据此找出永远不可能有实例的类。", @@ -1341,7 +1343,7 @@ export const zh: Strings = { schemaBundle: (n) => `${n} 条关系`, schemaOutgoing: "从这个类出发", schemaIncoming: "指向这个类", - schemaNoRelationships: "还没有关系。", + schemaNoRelationships: "这个类没有参与任何关系。继承信息显示在“定义”中。", schemaNoInstances: "还没有实例。", schemaConnectHint: "用一个已有的关系连接", schemaConnectPlaceholder: "搜索关系…", diff --git a/web/src/pages/Ontology.tsx b/web/src/pages/Ontology.tsx index 5dc57e252..242212e21 100644 --- a/web/src/pages/Ontology.tsx +++ b/web/src/pages/Ontology.tsx @@ -639,6 +639,7 @@ export function Ontology() { setSel({ kind: "class", id })} onNewSub={() => setEdit({ kind: "class", existing: null, parentId: selectedClass.id }) } @@ -1269,21 +1270,36 @@ function Description({ text }: { text: string | null | undefined }) { ); } -function ClassDefinition({ +export function ClassDefinition({ cls, allTypes, + onSelectClass, onNewSub, }: { cls: EntityTypeView; allTypes: EntityTypeView[]; + onSelectClass: (id: string) => void; /** 以当前类为父级新建子类:开弹窗 */ onNewSub: () => void; }) { const nameOf = (id: string) => allTypes.find((t) => t.id === id)?.label ?? id; + const classLinks = (ids: string[]) => + ids.map((id, index) => ( + + {index > 0 && ", "} + onSelectClass(id)}>{nameOf(id)} + + )); + const subclasses = allTypes.filter((type) => type.parents.includes(cls.id)); return (
- {cls.parents.length > 0 ? cls.parents.map(nameOf).join(", ") : S.ontology.noParent} + {cls.parents.length > 0 ? classLinks(cls.parents) : S.ontology.noParent} + + + {subclasses.length > 0 + ? classLinks(subclasses.map((type) => type.id)) + : S.ontology.noSubclasses} {cls.disjoint.length > 0 ? cls.disjoint.map(nameOf).join(", ") : S.ontology.noDisjoint}