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
32 changes: 0 additions & 32 deletions frontend/e2e/node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,38 +14,6 @@ test("Table of contents works", async ({ page }) => {
);

/** check if solo selection mode works */
await page.locator(".toc .checkbox").click();
await expect(
page
.locator("main")
.getByText(/Ehlers-Danlos syndrome, hypermobility/i)
.first(),
).toBeVisible();
await expect(
page
.locator("main")
.getByText(/Hierarchy/i)
.first(),
).not.toBeVisible();
await expect(
page
.locator("main")
.getByText(/Associations/i)
.first(),
).not.toBeVisible();
await page.locator(".toc .checkbox").click();
await expect(
page
.locator("main")
.getByText(/Hierarchy/i)
.first(),
).toBeVisible();
await expect(
page
.locator("main")
.getByText(/Associations/i)
.first(),
).toBeVisible();
});

test("Title info shows", async ({ page }) => {
Expand Down
2 changes: 1 addition & 1 deletion frontend/e2e/search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ test("Recent/frequent results show", async ({ page }) => {

for (const node of nodes) {
await page.goto("/" + node);
await expect(page.locator("#hierarchy")).toBeVisible();
await expect(page.locator("#breadcrumbs")).toBeVisible();
await page.waitForTimeout(500);
await page.goto("/");
await page.waitForSelector("input");
Expand Down
185 changes: 185 additions & 0 deletions frontend/e2e/sectionHierarchy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
/**
* - Validate title text derived from the node's category mapping.
* - Ensure parents/current/children render with truncation class.
* - Verify the inline child list respects `childLimit` and that the modal shows
* the remaining children when "+ more…" is clicked.
* - Exercise the `labelOf` fallback (id → label) when name/label are missing.
*/

import { defineComponent } from "vue";
import { describe, expect, it } from "vitest";
import { mount, RouterLinkStub } from "@vue/test-utils";
import SectionHeirarchy from "@/pages/node/SectionHierarchy.vue"; //

/**
* Minimal AppModal stub:
*
* - Accepts v-model (modelValue) and label props.
* - Emits update:modelValue like a proper v-model component.
* - Renders slot content only when open (modelValue === true).
*/
const AppModalStub = defineComponent({
name: "AppModal",
props: { modelValue: { type: Boolean, default: false }, label: String },
emits: ["update:modelValue"],
template: `<div v-if="modelValue" data-stub="modal"><slot /></div>`,
});
/**
* Helper to construct a Node-like object with predictable hierarchy shape. Lets
* each test override category/name/parents/children as needed.
*/
function makeNode({
category = "biolink:Disease",
name = "Ehlers-Danlos syndrome, hypermobility",
parents = ["Parent A", "Parent B"],
children = [
"Child 1",
"Child 2",
"Child 3",
"Child 4",
"Child 5",
"Child 6",
"Child 7",
"Child 8",
],
}: {
category?: string;
name?: string;
parents?: string[];
children?: string[];
}) {
return {
id: "MONDO:0000000",
name,
category,
node_hierarchy: {
super_classes: parents.map((p, i) => ({ id: `P${i + 1}`, name: p })),
// First child intentionally has only an id to exercise labelOf fallback.
sub_classes: children.map((c, i) =>
i === 0 ? { id: `C${i + 1}` } : { id: `C${i + 1}`, name: c },
),
},
} as any;
}

describe("TocHier.vue", () => {
it("renders title using category mapping", () => {
const wrapper = mount(SectionHeirarchy, {
props: { node: makeNode({}) },
global: { stubs: { RouterLink: RouterLinkStub, AppModal: AppModalStub } },
});

// Title should read "<MappedCategory> hierarchy" (e.g., "Disease hierarchy").
expect(wrapper.find(".toc-hier-title").text().toLowerCase()).toContain(
"disease hierarchy",
);
});

it("renders parent rows with truncating links", () => {
const node = makeNode({
parents: [
"A very very very long parent name that should truncate",
"Parent B",
],
});
const wrapper = mount(SectionHeirarchy, {
props: { node },
global: { stubs: { RouterLink: RouterLinkStub, AppModal: AppModalStub } },
});

// Parent rows exist and use the truncation class (.row-text).
const parents = wrapper.findAll(".parents .parent-row .row-text");
expect(parents.length).toBe(2);
expect(parents[0].classes()).toContain("row-text");
// We can’t assert actual CSS truncation, but text presence confirms binding.
expect(parents[0].text()).toContain("A very very very long parent name");
});

it("renders current row with truncation", () => {
const wrapper = mount(SectionHeirarchy, {
props: {
node: makeNode({
name: "A very very very long current node name that should truncate",
}),
},
global: { stubs: { RouterLink: RouterLinkStub, AppModal: AppModalStub } },
});

const current = wrapper.find(".current-row .row-text");
expect(current.exists()).toBe(true);
expect(current.classes()).toContain("row-text");
expect(current.text()).toContain("A very very very long current node name");
});

it('shows only first N children inline and a "+ more…" button for the rest (default limit = 6)', () => {
const node = makeNode({
children: ["C1", "C2", "C3", "C4", "C5", "C6", "C7", "C8"], // 8 total
});
const wrapper = mount(SectionHeirarchy, {
props: { node },
global: { stubs: { RouterLink: RouterLinkStub, AppModal: AppModalStub } },
});

// By default, childLimit is 6 (via computed).
const inlineChildren = wrapper.findAll(".children .child-row");
expect(inlineChildren.length).toBe(6);

// The “+ more…” button should indicate remaining children (8 - 6 = 2).
const moreBtn = wrapper.get("button.more");
expect(moreBtn.text()).toMatch(/\+\s*2 more/i);
});

it("respects childLimit prop", async () => {
const node = makeNode({ children: ["C1", "C2", "C3", "C4", "C5"] }); // 5 total
const wrapper = mount(SectionHeirarchy, {
props: { node, childLimit: 3 },
global: { stubs: { RouterLink: RouterLinkStub, AppModal: AppModalStub } },
});

// With limit 3, only 3 children are inline and 2 are hidden behind modal.
expect(wrapper.findAll(".children .child-row").length).toBe(3);

const moreBtn = wrapper.get("button.more");
expect(moreBtn.text()).toMatch(/\+\s*2 more/i);
});

it('opens modal and lists remaining children when clicking "+ more…"', async () => {
const node = makeNode({
children: ["C1", "C2", "C3", "C4", "C5", "C6", "C7", "C8"], // 8 total, 6 inline, 2 in modal
});
const wrapper = mount(SectionHeirarchy, {
props: { node },
global: { stubs: { RouterLink: RouterLinkStub, AppModal: AppModalStub } },
attachTo: document.body, // ensure event bubbling works in JSDOM
});

// Clicking "+ more…" toggles v-model to true, so the stub renders slot content.
await wrapper.get("button.more").trigger("click");

// Remaining children (after the first 6) should be rendered in the modal list.
const items = wrapper.findAll(".hier-modal-list li");
expect(items.length).toBe(2);

// Modal title should reflect pluralization and include the node name.
expect(wrapper.find(".modal-title").text().toLowerCase()).toContain(
"subclasses of",
);
});

it("labelOf fallback uses id when name/label missing (child row)", () => {
// makeNode creates first child with id only (“C1”), exercising the fallback.
const node = makeNode({
children: ["OnlyIdChild", "C2", "C3", "C4", "C5", "C6"],
});

const wrapper = mount(SectionHeirarchy, {
props: { node },
global: { stubs: { RouterLink: RouterLinkStub, AppModal: AppModalStub } },
});

const firstChildText = wrapper
.find(".children .child-row .row-text")
.text();
expect(firstChildText).toMatch(/^C1$/); // falls back to id
});
});
2 changes: 1 addition & 1 deletion frontend/src/components/AppBackToTopButton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,10 @@ const scrollTop = () => {
<style scoped lang="scss">
.toc-top {
display: flex;
z-index: 1;
justify-content: center;
padding: 1em;
border-bottom: 1px solid #e9eef0;
background: #fff;
font-size: 0.8em;
}
</style>
2 changes: 1 addition & 1 deletion frontend/src/components/AppModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ onBeforeUpdate(async () => {
max-height: calc(100vh - 80px);
padding: 40px;
overflow-y: auto;
gap: 40px;
gap: 12px;
background: $white;
}

Expand Down
24 changes: 19 additions & 5 deletions frontend/src/components/TheTableOfContents.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@
@click.stop
>
<AppBackToTopButton />

<SectionHierarchy
v-if="node && showHierarchy"
:node="node"
:child-limit="10"
/>

<!-- entries -->
<AppLink
v-for="(entry, index) in entries"
Expand All @@ -31,11 +38,11 @@
<div class="spacer"></div>

<!-- options -->
<AppCheckbox
<!-- <AppCheckbox
v-model="oneAtATime"
v-tooltip="'Only show one section at a time'"
text="Show single section"
/>
/> -->
</AppFlex>
</aside>
</template>
Expand All @@ -47,13 +54,15 @@ export const closeToc = (): unknown =>
</script>

<script setup lang="ts">
import { nextTick, onMounted, ref, watch } from "vue";
import { computed, nextTick, onMounted, ref, watch } from "vue";
import {
onClickOutside,
useEventListener,
useMutationObserver,
} from "@vueuse/core";
import type { Node as ApiNode } from "@/api/model";
import AppBackToTopButton from "@/components/AppBackToTopButton.vue";
import SectionHierarchy from "@/pages/node/SectionHierarchy.vue";
import { firstInView } from "@/util/dom";
import AppCheckbox from "./AppCheckbox.vue";
import type AppFlex from "./AppFlex.vue";
Expand All @@ -65,6 +74,12 @@ type Entries = {
text: string;
}[];

const CATEGORIES = [
"biolink:Disease",
"biolink:PhenotypicFeature",
"biolink:AnatomicalEntity",
];
const { node } = defineProps<{ node: ApiNode | null }>();
/** toc entries */
const entries = ref<Entries>([]);
/** whether toc is open or not */
Expand All @@ -75,10 +90,9 @@ const nudge = ref(0);
const oneAtATime = ref(false);
/** active (in view or selected) section */
const active = ref(0);

const showHierarchy = computed(() => CATEGORIES.includes(node?.category ?? ""));
/** table of contents panel element */
const toc = ref<InstanceType<typeof AppFlex>>();

/** listen for close event */
useEventListener(window, "closetoc", () => (expanded.value = false));
/** toggle expanded state */
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/global/variables.scss
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,4 @@ $outline: 0 0 0 2px $theme;
$shadow: 0 2px 4px #00000030;

/* table of contents width */
$toc-width: 210px;
$toc-width: 300px;
4 changes: 2 additions & 2 deletions frontend/src/pages/node/PageNode.vue
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@
<SectionVisualization :node="node" />
<SectionAssociations :node="node" />
<SectionBreadcrumbs :node="node" />
<SectionHierarchy :node="node" />
<!-- <SectionHierarchy :node="node" /> -->
<Teleport to="body">
<TheTableOfContents />
<TheTableOfContents :node="node" />
</Teleport>
</template>
</template>
Expand Down
Loading