diff --git a/client/src/components/BaseComponents/GPopover.test.ts b/client/src/components/BaseComponents/GPopover.test.ts new file mode 100644 index 000000000000..03b0882d8aa0 --- /dev/null +++ b/client/src/components/BaseComponents/GPopover.test.ts @@ -0,0 +1,107 @@ +import { mount, type Wrapper } from "@vue/test-utils"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type Vue from "vue"; + +import GPopover from "./GPopover.vue"; + +// happy-dom gives every element a zero-sized rect, so whatever placement floating-ui would +// really resolve to here is meaningless. Pin it and assert on what the component does with it. +const resolved = vi.hoisted(() => ({ placement: "bottom" })); + +vi.mock("@floating-ui/dom", () => ({ + computePosition: () => + Promise.resolve({ + x: 0, + y: 0, + placement: resolved.placement, + middlewareData: { arrow: { x: 8 } }, + }), + autoUpdate: (_reference: unknown, _floating: unknown, update: () => void) => { + update(); + return () => {}; + }, + arrow: () => ({ name: "arrow", fn: () => ({}) }), + flip: () => ({ name: "flip", fn: () => ({}) }), + offset: () => ({ name: "offset", fn: () => ({}) }), + shift: () => ({ name: "shift", fn: () => ({}) }), +})); + +let wrapper: Wrapper | undefined; + +// Queried off the document rather than the wrapper subtree so these stay valid whichever +// container the popover ends up rendering into. +function popoverEl() { + const el = document.body.querySelector(".popover"); + if (!el) { + throw new Error("popover element not found"); + } + return el; +} + +async function showPopover(placement: string, resolvedPlacement: string) { + resolved.placement = resolvedPlacement; + + const target = document.createElement("button"); + target.id = "trigger"; + document.body.appendChild(target); + + wrapper = mount(GPopover as object, { + attachTo: document.body, + propsData: { target: "trigger", placement, show: false }, + slots: { default: "body content" }, + }); + + // Positioning only kicks in when `show` transitions, so toggle rather than mounting shown. + await wrapper.setProps({ show: true }); + + // The arrow offset is the only signal that computePosition has actually resolved; the + // placement class already holds a default before then. + await vi.waitFor(() => { + if (!popoverEl().querySelector(".arrow")?.getAttribute("style")?.includes("left")) { + throw new Error("popover not positioned yet"); + } + }); +} + +describe("GPopover", () => { + afterEach(() => { + wrapper?.destroy(); + wrapper = undefined; + document.body.innerHTML = ""; + }); + + it.each([ + ["bottomleft", "bottom-start", "bs-popover-bottom"], + ["topleft", "top-start", "bs-popover-top"], + ["rightbottom", "right-end", "bs-popover-right"], + ["bottom", "bottom", "bs-popover-bottom"], + ["right", "right", "bs-popover-right"], + ])("placement %s resolving to %s gets the %s arrow class", async (placement, resolvedPlacement, expectedClass) => { + await showPopover(placement, resolvedPlacement); + + // Bootstrap only defines arrow styling for the four base sides, so an aligned + // placement still has to map onto its base side or the arrow renders untriangled. + expect([...popoverEl().classList]).toContain(expectedClass); + }); + + it("does not emit an aligned placement class Bootstrap has no rule for", async () => { + await showPopover("bottomleft", "bottom-start"); + + expect([...popoverEl().classList]).not.toContain("bs-popover-bottom-start"); + }); + + it("positions the arrow along the popover edge", async () => { + await showPopover("bottom", "bottom"); + + expect(popoverEl().querySelector(".arrow")?.getAttribute("style")).toContain("left: 8px"); + }); + + it("relocates the popover to the document body", async () => { + await showPopover("bottom", "bottom"); + + // Vue 2.7 has no built-in Teleport, so a bare leaves the popover nested in an + // unknown element and still subject to ancestor clipping. Being a direct child of body is + // the whole point of the escape hatch. + expect(popoverEl().parentElement).toBe(document.body); + }); +}); diff --git a/client/src/components/BaseComponents/GPopover.vue b/client/src/components/BaseComponents/GPopover.vue new file mode 100644 index 000000000000..46e3abf10b00 --- /dev/null +++ b/client/src/components/BaseComponents/GPopover.vue @@ -0,0 +1,432 @@ + + + + + diff --git a/client/src/components/Collections/common/CellStatusComponent.vue b/client/src/components/Collections/common/CellStatusComponent.vue index 6e5acba93e0c..09ef74c41b7e 100644 --- a/client/src/components/Collections/common/CellStatusComponent.vue +++ b/client/src/components/Collections/common/CellStatusComponent.vue @@ -4,12 +4,13 @@ import { faCheck, faExclamationTriangle } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; import type { ICellRendererParams } from "ag-grid-community"; -import { BPopover } from "bootstrap-vue"; import { defineComponent } from "vue"; +import GPopover from "@/components/BaseComponents/GPopover.vue"; + export default defineComponent({ components: { - BPopover, + GPopover, FontAwesomeIcon, }, data() { @@ -59,7 +60,7 @@ export default defineComponent({ diff --git a/client/src/components/Common/DatasetPopoverLink.vue b/client/src/components/Common/DatasetPopoverLink.vue index 7c0b28dbfbbf..40a86db034df 100644 --- a/client/src/components/Common/DatasetPopoverLink.vue +++ b/client/src/components/Common/DatasetPopoverLink.vue @@ -1,11 +1,11 @@ diff --git a/client/src/components/FileSources/Templates/TemplateSummaryPopover.vue b/client/src/components/FileSources/Templates/TemplateSummaryPopover.vue index c056e03d9b72..9465b85434ff 100644 --- a/client/src/components/FileSources/Templates/TemplateSummaryPopover.vue +++ b/client/src/components/FileSources/Templates/TemplateSummaryPopover.vue @@ -5,7 +5,7 @@ import TemplateSummary from "./TemplateSummary.vue"; import TemplateSummaryPopover from "@/components/ConfigTemplates/TemplateSummaryPopover.vue"; interface Props { - target: String; + target: string; template: FileSourceTemplateSummary; } diff --git a/client/src/components/Form/Elements/FormData/FormDataExtensions.vue b/client/src/components/Form/Elements/FormData/FormDataExtensions.vue index 1a55e500dc54..d3a3fe75b2a2 100644 --- a/client/src/components/Form/Elements/FormData/FormDataExtensions.vue +++ b/client/src/components/Form/Elements/FormData/FormDataExtensions.vue @@ -1,13 +1,13 @@ - - - diff --git a/client/src/components/History/StorageOperations/StorageOperationOutcomeProgress.vue b/client/src/components/History/StorageOperations/StorageOperationOutcomeProgress.vue index 4309b687df6c..ad82eba51272 100644 --- a/client/src/components/History/StorageOperations/StorageOperationOutcomeProgress.vue +++ b/client/src/components/History/StorageOperations/StorageOperationOutcomeProgress.vue @@ -1,9 +1,11 @@