fix(ux): confirm modal's Cancel/Confirm were dead on the promise-API path
Release / build-and-push (push) Successful in 3m29s

User report: the Purge Archived dialog ('Permanently delete all
archived items?...') opened, and Cancel did nothing — and Confirm
would have been equally dead, leaving the page stuck behind the
overlay until a reload.

Root cause: confirm.ts wired the modal's buttons ONLY inside the
htmx:confirm interceptor. confirmDialog() — the promise API used by
purgeArchivedItems, remove-account, and other TS call sites — never
wired them. So whenever a page's first confirm interaction came from
the promise path (no hx-confirm element clicked first), both buttons
had no listeners. The 2026-10-30 confirm_probe spec only exercised the
hx-confirm path, which is why it survived.

Fix: wireButtons() now runs at module init (the header renders before
deferred modules) and inside confirmDialog() — both idempotent via the
existing data-wired guard.

Pinned by tests/ui/promise_confirm_probe.spec.ts (no env gating): the
purge POST is route-intercepted, so the probe asserts Cancel closes
the modal and Confirm issues the guarded request without deleting any
dev data. hx-confirm path re-pinned green (confirm_probe with BH_FEED).
Verified manually at 390x844: open -> cancelWired=true -> Cancel
closes -> Confirm fires the intercepted request.
This commit is contained in:
John O'Keefe committed 2026-11-02 11:24:55 -05:00
1 parent 04e855e691
commit 2165829d0e
3 files changed
+74 -1

No files matched your search

+9
View File
@@ -40,6 +40,15 @@ Gate: every line below executes for BOTH personas unless marked
- 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-11-02 — promise-API confirm probe
`promise_confirm_probe.spec.ts` (no env gating): the confirmDialog()
promise path (purge archived, remove account, …) must wire the global
modal's buttons even when no hx-confirm element fired first — the
Purge All Archived dialog opened with dead Cancel/Confirm otherwise
(user report; confirm.ts now wires at init + on every confirmDialog).
The probe intercepts the purge POST, so nothing is deleted.
## 2026-10-30 — round-3 regression probes (live-server, env-gated) ## 2026-10-30 — round-3 regression probes (live-server, env-gated)
Two specs added against a RUNNING dev server (skip unless Two specs added against a RUNNING dev server (skip unless
+53
View File
@@ -0,0 +1,53 @@
// Regression probe (2026-11-02): the CONFIRM PROMISE API path must wire
// the global modal's buttons too. purgeArchivedItems() (admin archived
// page) opens confirmDialog() directly — no hx-confirm element fires —
// and both buttons were dead whenever no hx-confirm had been clicked
// first: the modal opened and Cancel/Confirm did nothing (user report).
// The purge request itself is INTERCEPTED here — nothing is deleted.
import { test, expect } from "@playwright/test";
const BASE = process.env.BH_BASE ?? "http://localhost:8765";
test("confirmDialog promise path wires Cancel/Confirm", async ({ page }) => {
await page.addInitScript(() => {
const t = new URLSearchParams(window.location.search).get("token");
if (t) localStorage.setItem("token", t);
});
// testuser login via endpoint, then seed cookie+localStorage like the
// other probes (rls_sweep pattern, kept inline to stay one file).
const res = await page.request.post(`${BASE}/api/auth/login`, {
data: { login: "testuser", password: "Test@Pass123!" },
});
const { access_token: token } = (await res.json()) as { access_token?: string };
expect(token).toBeTruthy();
await page.context().addCookies([{ name: "token", value: token!, url: BASE }]);
await page.addInitScript((t) => localStorage.setItem("token", t), token!);
// The purge POST is intercepted: Cancel/Confirm behavior is asserted
// without ever deleting the dev data.
let purgeRequested = false;
await page.route("**/api/media-items/purge-archived", (route) => {
purgeRequested = true;
route.fulfill({ status: 200, contentType: "application/json", body: '{"purged":0}' });
});
await page.goto(`${BASE}/admin/archived`);
await expect(page.locator("#confirm-modal")).toBeAttached();
// Fresh page: no hx-confirm has fired, so pre-fix the buttons were
// unwired (data-wired absent) and Cancel did nothing.
await page.getByRole("button", { name: "Purge All Archived" }).click();
const modal = page.locator("#confirm-modal");
await expect(modal).toBeVisible();
await expect(page.locator("#confirm-modal-cancel")).toHaveAttribute("data-wired", "1");
await page.locator("#confirm-modal-cancel").click();
await expect(modal).toBeHidden();
// Confirm path: modal closes AND the guarded request issues.
await page.getByRole("button", { name: "Purge All Archived" }).click();
await expect(modal).toBeVisible();
await page.locator("#confirm-modal-accept").click();
await expect(modal).toBeHidden();
expect(purgeRequested).toBe(true);
});
+12 -1
View File
@@ -25,6 +25,7 @@ function hide(ok: boolean): void {
* Safe to call from any module (import this instead of window.confirm). * Safe to call from any module (import this instead of window.confirm).
*/ */
export function confirmDialog(message: string): Promise<boolean> { export function confirmDialog(message: string): Promise<boolean> {
wireButtons();
const { overlay, message: messageEl } = modalElements(); const { overlay, message: messageEl } = modalElements();
if (!overlay || !messageEl) { if (!overlay || !messageEl) {
// Header missing (bare layouts): degrade to the native dialog // Header missing (bare layouts): degrade to the native dialog
@@ -38,7 +39,13 @@ export function confirmDialog(message: string): Promise<boolean> {
}); });
} }
// Wire the static buttons once. // Wire the static buttons once. Called at module init AND from both
// entry paths below (idempotent via data-wired): the htmx:confirm
// interceptor and the confirmDialog promise API. Wiring only on the
// htmx path left the promise path (purge archived, remove account, ...)
// with dead Cancel/Confirm buttons whenever no hx-confirm element had
// been clicked first — the modal opened and nothing could close it
// (user report: Purge Archived, 2026-11-02).
function wireButtons(): void { function wireButtons(): void {
const { overlay } = modalElements(); const { overlay } = modalElements();
const accept = document.getElementById("confirm-modal-accept"); const accept = document.getElementById("confirm-modal-accept");
@@ -60,6 +67,10 @@ function wireButtons(): void {
} }
} }
// The header (and its confirm modal) render before deferred modules run,
// so the elements are present at import time.
wireButtons();
// 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".)