fix(ui): hx-confirm interception actually worked — htmx 2 detail shape
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 <details>
'+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.
This commit is contained in:
1 parent
2fd0828c06
commit
ace9e57d27
4 files changed
+96
-6
No files matched your search
@@ -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.
|
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
|
- Row 23 chains by page navigation — assert the URL becomes the next
|
||||||
episode's reader and progress carries over.
|
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=<feed uuid>` 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`).
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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=<feed uuid> 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();
|
||||||
|
});
|
||||||
+11
-6
@@ -63,15 +63,20 @@ function wireButtons(): void {
|
|||||||
// htmx:confirm interceptor: every element with hx-confirm gets the
|
// htmx:confirm interceptor: every element with hx-confirm gets the
|
||||||
// modal instead of the browser dialog; confirming issues the request.
|
// modal instead of the browser dialog; confirming issues the request.
|
||||||
// (Canonical htmx pattern — see htmx docs, "confirming with a modal".)
|
// (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) {
|
document.body.addEventListener("htmx:confirm", function (evt) {
|
||||||
const custom = evt as CustomEvent<{
|
const custom = evt as CustomEvent<{
|
||||||
element: HTMLElement;
|
elt?: HTMLElement;
|
||||||
issueRequest: (override?: boolean) => void;
|
question?: string;
|
||||||
|
issueRequest: (skipConfirmation?: boolean) => void;
|
||||||
}>;
|
}>;
|
||||||
const elt = custom.detail.element;
|
const question = custom.detail.question;
|
||||||
if (!elt) return;
|
if (!question) return; // not a confirm-gated request — normal flow
|
||||||
const question = elt.getAttribute("hx-confirm");
|
|
||||||
if (question === null) return;
|
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
wireButtons();
|
wireButtons();
|
||||||
confirmDialog(question).then((ok) => {
|
confirmDialog(question).then((ok) => {
|
||||||
|
|||||||
Reference in new issue
Block a user