From 9d32905d318389976821812d8016d72b35c26ac1 Mon Sep 17 00:00:00 2001 From: 81reap Date: Tue, 29 Sep 2026 19:49:36 -0400 Subject: [PATCH] fix(frontend) :: remove `any` types from `sqlpage.ts` `sqlpage.ts` now has no implicit anys. Fixing this smoked out two bugs :: 1. `init_bootstrap_components` read `event.target` without knowing what kind of element. We also tighting scope from `Element` to `HTMLElement`. 2. `normalize_hash` passed `undefined` to `decodeURIComponent`, which stringifies it, so a toast with no `data-toast-trigger` opened at `#undefined`. --- frontend/src/sqlpage.ts | 40 ++++++++++++------- .../fixtures/fragment-loaded/index.sql | 1 + .../fixtures/fragment-loaded/test.ts | 40 +++++++++++++++++++ 3 files changed, 67 insertions(+), 14 deletions(-) create mode 100644 tests/end-to-end/fixtures/fragment-loaded/index.sql create mode 100644 tests/end-to-end/fixtures/fragment-loaded/test.ts diff --git a/frontend/src/sqlpage.ts b/frontend/src/sqlpage.ts index 4e6ce7b8..f06685a7 100644 --- a/frontend/src/sqlpage.ts +++ b/frontend/src/sqlpage.ts @@ -372,7 +372,7 @@ function sqlpage_form() { } } -function get_tabler_color(name) { +function get_tabler_color(name: string) { return getComputedStyle(document.documentElement).getPropertyValue( `--tblr-${name}`, ); @@ -394,8 +394,8 @@ function load_scripts() { } } -function normalize_hash(hash) { - const normalized = hash?.replace(/^#/, ""); +function normalize_hash(hash: string | undefined) { + const normalized = hash?.replace(/^#/, "") ?? ""; try { return decodeURIComponent(normalized); } catch { @@ -403,7 +403,7 @@ function normalize_hash(hash) { } } -function open_toasts_for_hash(toasts) { +function open_toasts_for_hash(toasts: Iterable) { const Toast = page_bootstrap().Toast; const hash = normalize_hash(window.location.hash); if (!hash) return; @@ -414,9 +414,9 @@ function open_toasts_for_hash(toasts) { } } -function restore_focus_after_toast(toast, container) { +function restore_focus_after_toast(toast: HTMLElement, container: HTMLElement) { if (!toast.contains(document.activeElement)) return; - const next_close = container.querySelector( + const next_close = container.querySelector( '.toast.show [data-bs-dismiss="toast"]', ); if (next_close) { @@ -501,27 +501,39 @@ add_init_fn(sqlpage_modal); add_init_fn(load_scripts); add_init_fn(sqlpage_toast); window.addEventListener("hashchange", () => - open_toasts_for_hash(document.querySelectorAll("[data-toast-trigger]")), + open_toasts_for_hash( + document.querySelectorAll("[data-toast-trigger]"), + ), ); -function init_bootstrap_components(event) { +function init_bootstrap_components(fragment: Element | Document) { const bootstrap = page_bootstrap(); - const fragment = event.target; - for (const el of fragment.querySelectorAll('[data-bs-toggle="tooltip"]')) { + for (const el of fragment.querySelectorAll( + '[data-bs-toggle="tooltip"]', + )) { new bootstrap.Tooltip(el); } - for (const el of fragment.querySelectorAll('[data-bs-toggle="popover"]')) { + for (const el of fragment.querySelectorAll( + '[data-bs-toggle="popover"]', + )) { new bootstrap.Popover(el); } - for (const el of fragment.querySelectorAll('[data-bs-toggle="dropdown"]')) { + for (const el of fragment.querySelectorAll( + '[data-bs-toggle="dropdown"]', + )) { new bootstrap.Dropdown(el); } - for (const el of fragment.querySelectorAll('[data-bs-ride="carousel"]')) { + for (const el of fragment.querySelectorAll( + '[data-bs-ride="carousel"]', + )) { new bootstrap.Carousel(el); } } -document.addEventListener("fragment-loaded", init_bootstrap_components); +document.addEventListener("fragment-loaded", ({ target }) => { + if (target instanceof Element || target instanceof Document) + init_bootstrap_components(target); +}); function open_modal_for_hash() { const hash = window.location.hash.substring(1); diff --git a/tests/end-to-end/fixtures/fragment-loaded/index.sql b/tests/end-to-end/fixtures/fragment-loaded/index.sql new file mode 100644 index 00000000..59b04c74 --- /dev/null +++ b/tests/end-to-end/fixtures/fragment-loaded/index.sql @@ -0,0 +1 @@ +SELECT 'text' AS component, 'Markup added after load is initialized when a fragment is announced.' AS contents; diff --git a/tests/end-to-end/fixtures/fragment-loaded/test.ts b/tests/end-to-end/fixtures/fragment-loaded/test.ts new file mode 100644 index 00000000..b82c3298 --- /dev/null +++ b/tests/end-to-end/fixtures/fragment-loaded/test.ts @@ -0,0 +1,40 @@ +import { expect, type Page, test } from "../../fixture.ts"; + +const INJECTED_HINT = "injected hint"; + +async function addTooltip(page: Page) { + await page.evaluate((hint) => { + const span = document.createElement("span"); + span.id = "added"; + span.textContent = "added"; + span.setAttribute("data-bs-toggle", "tooltip"); + span.setAttribute("title", hint); + document.querySelector("main")?.appendChild(span); + }, INJECTED_HINT); +} + +test("shows a tooltip added before the document announces a fragment", async ({ + page, +}) => { + await addTooltip(page); + await page.evaluate(() => + document.dispatchEvent(new CustomEvent("fragment-loaded")), + ); + await page.locator("#added").hover(); + + await expect(page.locator(".tooltip")).toHaveText(INJECTED_HINT); +}); + +test("shows a tooltip added before an element announces a fragment", async ({ + page, +}) => { + await addTooltip(page); + await page.evaluate(() => + document + .querySelector("main") + ?.dispatchEvent(new CustomEvent("fragment-loaded", { bubbles: true })), + ); + await page.locator("#added").hover(); + + await expect(page.locator(".tooltip")).toHaveText(INJECTED_HINT); +});