diff --git a/public/app.css b/public/app.css index bf53f60..0906692 100644 --- a/public/app.css +++ b/public/app.css @@ -4422,3 +4422,9 @@ select.f { .planner-page .header-row-top { max-width: 100%; } + +.delete-plan-dialog { width: min(500px, 94vw); } +#delete-plan-name { font-weight: 700; overflow-wrap: anywhere; } +#delete-plan-description { line-height: 1.6; } +#delete-plan-error { color: #a32920; } +.delete-plan-actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 24px; } diff --git a/public/js/plans.js b/public/js/plans.js index fc700e1..5f95426 100644 --- a/public/js/plans.js +++ b/public/js/plans.js @@ -18,6 +18,41 @@ function node(tag, text, className = "") { el.className = className; return el; } +const deleteDialog = document.getElementById("delete-plan-dialog"), + deleteName = document.getElementById("delete-plan-name"), + deleteError = document.getElementById("delete-plan-error"), + deleteConfirm = document.getElementById("delete-plan-confirm"), + deleteCancel = document.getElementById("delete-plan-cancel"); +let pendingDelete = null, deleting = false; +deleteCancel.onclick = () => deleteDialog.close(); +deleteDialog.addEventListener("cancel", (event) => { + if (deleting) event.preventDefault(); +}); +deleteDialog.addEventListener("close", () => { pendingDelete = null; }); +deleteConfirm.onclick = async () => { + if (!pendingDelete || deleting) return; + deleting = true; + deleteConfirm.disabled = deleteCancel.disabled = true; + deleteConfirm.textContent = "Deleting…"; + deleteError.textContent = ""; + try { + try { + await api("/api/plans/" + pendingDelete.id, { method: "DELETE" }); + } catch (error) { + // Another tab may already have removed this plan. + if (error.status !== 404) throw error; + } + deleteDialog.close(); + await load(); + showToast("Plan deleted."); + } catch (error) { + deleteError.textContent = "Could not delete this plan. " + error.message + " Try again."; + } finally { + deleting = false; + deleteConfirm.disabled = deleteCancel.disabled = false; + deleteConfirm.textContent = "Delete plan"; + } +}; async function load() { const current = ++generation; const params = new URLSearchParams(new FormData(form)); @@ -92,14 +127,11 @@ async function load() { const remove = node("button", "Delete", "ghost-btn small"); remove.type = "button"; remove.setAttribute("aria-label", "Delete " + plan.title); - remove.onclick = async () => { - if (!confirm(`Delete “${plan.title}”?`)) return; - try { - await api("/api/plans/" + plan.id, { method: "DELETE" }); - await load(); - } catch (e) { - showToast(e.message, "fail"); - } + remove.onclick = () => { + pendingDelete = plan; + deleteName.textContent = plan.title; + deleteError.textContent = ""; + deleteDialog.showModal(); }; actions.append(clone, rename, remove); tr.append(actions); diff --git a/public/plans.html b/public/plans.html index 550b559..d5b8bf2 100644 --- a/public/plans.html +++ b/public/plans.html @@ -151,6 +151,16 @@ + +

Delete this plan?

+

+

This removes the saved plan and its revision history. Completed roast records and inventory history will be kept. This cannot be undone.

+ +
+ + +
+
diff --git a/test/report-browser.test.js b/test/report-browser.test.js index 19628be..d8d084e 100644 --- a/test/report-browser.test.js +++ b/test/report-browser.test.js @@ -717,3 +717,47 @@ test( assert.deepEqual(errors, []); }, ); + +test("browser: deleting a plan confirms in-page, supports retry, and preserves roast records", browserOptions, async (t) => { + const { page, base, create, db, csrfToken, errors } = await session(t); + const plan = blankPlan(); + const saved = await create(plan, "Burundi — previous light roast plan"); + const keep = await create(plan, "Keep this plan"); + await page.request.put(base + "/api/plans/" + saved.id, { + headers: { "x-csrf-token": csrfToken }, data: { name: "Burundi — previous light roast plan revised" }, + }); + const owner = (await db.query("SELECT user_id FROM roast_plans WHERE id=$1", [saved.id])).rows[0].user_id; + const roast = (await db.query("INSERT INTO actual_roasts(user_id,roast_plan_id,filename,original_content,parsed,plan_snapshot) VALUES($1,$2,'saved.alog','{}',$3,$4) RETURNING id", [owner,saved.id,{roast:{}},plan])).rows[0]; + // Browser popup suppression must not turn Delete into a silent no-op. + await page.addInitScript(() => { window.confirm = () => false; }); + await page.goto(base + "/plans"); + const remove = page.getByRole("button", {name: "Delete Burundi — previous light roast plan revised", exact:true}); + await remove.click(); + const dialog = page.getByRole("dialog", {name:"Delete this plan?"}); + await dialog.waitFor(); + await page.screenshot({path:"/tmp/delete-plan-desktop.png"}); + await page.setViewportSize({width:390,height:844}); + await page.screenshot({path:"/tmp/delete-plan-mobile.png"}); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); + await dialog.getByRole("button",{name:"Cancel",exact:true}).click(); + assert.equal((await page.request.get(base + "/api/plans/" + saved.id)).status(),200); + await remove.click(); + await page.route("**/api/plans/" + saved.id, async (route) => { + if (route.request().method() === "DELETE") await route.fulfill({status:503,contentType:"application/json",body:JSON.stringify({error:"Temporary service failure"})}); + else await route.continue(); + }); + await dialog.getByRole("button",{name:"Delete plan",exact:true}).click(); + await page.getByRole("alert").filter({hasText:"Temporary service failure"}).waitFor(); + assert.equal(await dialog.isVisible(),true); + await page.unroute("**/api/plans/" + saved.id); + await dialog.getByRole("button",{name:"Delete plan",exact:true}).click(); + await dialog.waitFor({state:"hidden"}); + await page.waitForFunction(() => document.getElementById("plans-status").textContent === "1–1 of 1 plans"); + assert.equal((await page.request.get(base + "/api/plans/" + saved.id)).status(),404); + assert.equal((await page.request.get(base + "/api/plans/" + keep.id)).status(),200); + assert.equal((await db.query("SELECT * FROM plan_revisions WHERE plan_id=$1",[saved.id])).rows.length,0); + const remaining = (await db.query("SELECT * FROM actual_roasts WHERE id=$1",[roast.id])).rows[0]; + assert.equal(remaining.roast_plan_id,null); + assert.deepEqual(remaining.plan_snapshot,plan); + assert.deepEqual(errors,[]); +});