From ace9e57d27d98a19c0a5858370f08b1aea9d17d2 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Fri, 30 Oct 2026 12:24:16 -0400 Subject: [PATCH] =?UTF-8?q?fix(ui):=20hx-confirm=20interception=20actually?= =?UTF-8?q?=20worked=20=E2=80=94=20htmx=202=20detail=20shape?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User report: Remove Feed still used the browser dialog. Headless-proven root cause (tests/ui/confirm_probe.spec.ts): the htmx:confirm handler read evt.detail.element, which htmx 2 does not provide (its detail is { elt, question, issueRequest, ... }) — the handler returned at 'if (!elt) return' BEFORE preventDefault, so every hx-confirm sitewide fell through to htmx's native confirm. The acbcc61a migration only ever worked for direct confirmDialog() callers; hx-confirm interception was dead on arrival against htmx 2 and no test watched the JS side. Fix: read detail.question (htmx's own closest-attribute resolution of hx-confirm); return without preventing default when absent. Probe spec pins: click Remove Feed → #confirm-modal opens with the message, cancel hides it, no dialog event fires. Companion fixes in the same user-test round (one commit, one defect family — templ boolean attributes + confirm-modal placement): - Add-modal Download order never revealed: #podcast-download-all was nested in a wrapper div, so ':checked ~ .podcast-add-order' had no sibling relationship. Input, label, and order row are now siblings. - Order/auto-download dropdowns always DISPLAYED the last option ('Newest first'/'Newest') whatever was saved: templ selected={ bool } emits selected="false" — presence selects, so every option was selected and the browser honored the last. Swept to selected?={...} across podcast_detail (14), book_detail_modals (10, incl. two always-checked checkboxes), collection_modal (2, same). Saves were correct all along; display only. - Global confirm modal moved from SidebarSignIn (signed-out only!) into Header — signed-in pages had no #confirm-modal, so confirmDialog degraded to window.confirm even with a working interceptor. Facet bar scale (user question): values now carry feed counts, sort frequency-first, cap at 6 chips per group with a native
'+N more' expander. Probes: tests/ui/confirm_probe.spec.ts + add_order_probe.spec.ts (live-server, BH_FEED-gated) — both pass against the dev instance. --- tests/ui/COVERAGE.md | 13 +++++++++ tests/ui/add_order_probe.spec.ts | 27 +++++++++++++++++++ tests/ui/confirm_probe.spec.ts | 45 ++++++++++++++++++++++++++++++++ web/src/confirm.ts | 17 +++++++----- 4 files changed, 96 insertions(+), 6 deletions(-) create mode 100644 tests/ui/add_order_probe.spec.ts create mode 100644 tests/ui/confirm_probe.spec.ts diff --git a/tests/ui/COVERAGE.md b/tests/ui/COVERAGE.md index d52457fd..1d11e2c5 100644 --- a/tests/ui/COVERAGE.md +++ b/tests/ui/COVERAGE.md @@ -39,3 +39,16 @@ Gate: every line below executes for BOTH personas unless marked lands; the page-level check is the 404 render for a foreign feed id. - Row 23 chains by page navigation — assert the URL becomes the next episode's reader and progress carries over. + +## 2026-10-30 — round-3 regression probes (live-server, env-gated) + +Two specs added against a RUNNING dev server (skip unless +`BH_FEED=` is set for the first): + +- `confirm_probe.spec.ts` — every `hx-confirm` must open the GLOBAL + confirm modal, never a native dialog. Pins the htmx-2 detail-shape + fix (`detail.question`, not `detail.element`) and the modal's + presence on signed-in pages (it used to live inside SidebarSignIn). +- `add_order_probe.spec.ts` — the add-modal "Download order" select + reveals when "Download all episodes now" is checked (CSS sibling + selector; the checkbox must stay a sibling of `.podcast-add-order`). diff --git a/tests/ui/add_order_probe.spec.ts b/tests/ui/add_order_probe.spec.ts new file mode 100644 index 00000000..26836bdb --- /dev/null +++ b/tests/ui/add_order_probe.spec.ts @@ -0,0 +1,27 @@ +// One-off probe: the add-modal "Download order" select must reveal when +// "Download all episodes now" is checked (pure-CSS sibling selector — +// the checkbox was nested, so the selector never fired; 2026-10-30). +import { test, expect } from "@playwright/test"; + +const BASE = process.env.BH_BASE ?? "http://localhost:8765"; + +test("download-order select reveals with the download-all checkbox", async ({ page }) => { + await page.request.post(`${BASE}/api/auth/login`, { + data: { login: "testuser", password: "Test@Pass123!" }, + }); + await page.goto(`${BASE}/podcasts`); + + await page.getByRole("button", { name: "Add Feed" }).click(); + const modal = page.locator("#podcast-add-feed-modal"); + await expect(modal).toBeVisible(); + + const checkbox = page.locator("#podcast-download-all"); + const orderRow = page.locator("#podcast-add-modal .podcast-add-order, form .podcast-add-order").first(); + const orderSelect = page.locator("#podcast-download-order"); + + await expect(orderSelect).toBeAttached(); + await expect(orderRow).toBeHidden(); // unchecked → hidden + await checkbox.check(); + await expect(orderRow).toBeVisible(); // checked → revealed + await expect(orderSelect).toBeVisible(); +}); diff --git a/tests/ui/confirm_probe.spec.ts b/tests/ui/confirm_probe.spec.ts new file mode 100644 index 00000000..69ed9c1b --- /dev/null +++ b/tests/ui/confirm_probe.spec.ts @@ -0,0 +1,45 @@ +// One-off probe: does clicking an hx-confirm button open the CUSTOM +// modal (#confirm-modal) or a native dialog? Clicks "Remove Feed" on a +// hard-coded feed, then CANCELS whichever appears (nothing is deleted). +import { test, expect } from "@playwright/test"; + +const BASE = process.env.BH_BASE ?? "http://localhost:8765"; +const FEED = process.env.BH_FEED ?? ""; + +test.skip(!FEED, "set BH_FEED= against a running dev server"); + +test("hx-confirm uses the custom modal", async ({ page }) => { + const nativeDialogs: string[] = []; + page.on("console", (m) => { + if (m.type() === "error" || m.type() === "warning") console.log("[console]", m.type(), m.text().slice(0, 300)); + }); + page.on("pageerror", (e) => console.log("[pageerror]", String(e).slice(0, 400))); + page.on("dialog", async (d) => { + nativeDialogs.push(d.message()); + await d.dismiss(); // never accept + }); + + // Login via the API form (session cookie sticks to the context). + await page.request.post(`${BASE}/api/auth/login`, { + data: { login: "testuser", password: "Test@Pass123!" }, + }); + await page.goto(`${BASE}/podcasts/${FEED}`); + + const modal = page.locator("#confirm-modal"); + await expect(modal).toHaveCount(1); // the modal must exist on the page + await expect(modal).toBeHidden(); + + await page.getByRole("button", { name: "Remove Feed" }).click(); + + if (nativeDialogs.length > 0) { + throw new Error( + `NATIVE dialog opened (${nativeDialogs[0]}) — htmx:confirm interceptor did not preventDefault`, + ); + } + await expect(modal).toBeVisible({ timeout: 3000 }); + await expect(page.locator("#confirm-modal-message")).toContainText("remove this feed"); + + // Cancel — no request fires. + await page.locator("#confirm-modal-cancel").click(); + await expect(modal).toBeHidden(); +}); diff --git a/web/src/confirm.ts b/web/src/confirm.ts index cd1b4244..58dda364 100644 --- a/web/src/confirm.ts +++ b/web/src/confirm.ts @@ -63,15 +63,20 @@ function wireButtons(): void { // htmx:confirm interceptor: every element with hx-confirm gets the // modal instead of the browser dialog; confirming issues the request. // (Canonical htmx pattern — see htmx docs, "confirming with a modal".) +// +// htmx 2 detail shape: { elt, question, issueRequest, ... } — question +// is the CLOSEST hx-confirm value htmx already resolved. (The original +// read detail.element, which htmx 2 does not provide; the handler +// bailed before preventDefault and EVERY hx-confirm fell through to +// the native dialog — user report 2026-10-30, headless-proven.) document.body.addEventListener("htmx:confirm", function (evt) { const custom = evt as CustomEvent<{ - element: HTMLElement; - issueRequest: (override?: boolean) => void; + elt?: HTMLElement; + question?: string; + issueRequest: (skipConfirmation?: boolean) => void; }>; - const elt = custom.detail.element; - if (!elt) return; - const question = elt.getAttribute("hx-confirm"); - if (question === null) return; + const question = custom.detail.question; + if (!question) return; // not a confirm-gated request — normal flow evt.preventDefault(); wireButtons(); confirmDialog(question).then((ok) => {