fix(ux): confirm modal's Cancel/Confirm were dead on the promise-API path
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:
1 parent
04e855e691
commit
99390c965c
3 files changed
+74
-1
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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".)
|
||||||
|
|||||||
Reference in new issue
Block a user