Serve delete, move and merge dialogs as htmx fragments

The server renders the dialogs (?dialog=delete|move) and answers a move
that needs confirmation with the merge dialog itself, so the client no
longer builds action URLs or interprets X-Merge-Available. Successful
mutations send HX-Redirect, which hx.js turns into a history replace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 16:37:46 +02:00
co-authored by Claude Opus 5.5
parent 465aed47b3
commit 3a3fb3b51f
11 changed files with 371 additions and 230 deletions
+50 -3
View File
@@ -2,7 +2,7 @@
// visible labels, not implementation details, so they keep passing when the
// client-side mechanics change (e.g. the move to HTMX).
import { test, expect } from "@playwright/test";
import { mkdirSync, readFileSync } from "node:fs";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
@@ -78,7 +78,7 @@ test("move renames a page", async ({ page }) => {
await pageAction(page, "Move page (M)");
// Tree picker preselects the current parent; keep it.
await modal(page).getByRole("button", { name: "NEXT" }).click();
await modal(page).locator("input").fill("Gamma");
await modal(page).getByPlaceholder("Page name").fill("Gamma");
await modal(page).getByRole("button", { name: "MOVE" }).click();
await expect(page).toHaveURL(/\/Projects\/Gamma\/$/);
await expect(page.locator("main h1")).toContainText("Beta");
@@ -89,7 +89,7 @@ test("move onto an empty folder offers a merge", async ({ page }) => {
await page.goto("/Projects/Alpha/");
await pageAction(page, "Move page (M)");
await modal(page).getByRole("button", { name: "NEXT" }).click();
await modal(page).locator("input").fill("Empty");
await modal(page).getByPlaceholder("Page name").fill("Empty");
await modal(page).getByRole("button", { name: "MOVE" }).click();
await expect(modal(page)).toContainText("Merge folders?");
await modal(page).getByRole("button", { name: "MERGE" }).click();
@@ -105,3 +105,50 @@ test("delete removes a page and returns to the parent", async ({ page }) => {
await expect(page).toHaveURL(/127\.0\.0\.1:\d+\/$/);
await expect(sidebar(page).getByRole("link", { name: "Notes" })).toHaveCount(0);
});
test("delete replaces the history entry, so Back skips the deleted page", async ({ page }) => {
mkdirSync(join(wiki, "Scratch", "Gone"), { recursive: true });
writeFileSync(join(wiki, "Scratch", "Gone", "index.md"), "# Gone\n");
await page.goto("/Scratch/");
await page.goto("/Scratch/Gone/");
await pageAction(page, "Delete page");
await modal(page).getByRole("button", { name: "DELETE" }).click();
await expect(page).toHaveURL(/\/Scratch\/$/);
await page.goBack();
await expect(page).toHaveURL(/\/Scratch\/$/);
});
test("server dialogs close with Escape and CANCEL", async ({ page }) => {
await page.goto("/Projects/");
await pageAction(page, "Delete page");
await expect(modal(page)).toBeVisible();
// CANCEL is focused, so Enter must not delete.
await expect(modal(page).getByRole("button", { name: "CANCEL" })).toBeFocused();
await page.keyboard.press("Escape");
await expect(modal(page)).toHaveCount(0);
await pageAction(page, "Delete page");
await modal(page).getByRole("button", { name: "CANCEL" }).click();
await expect(modal(page)).toHaveCount(0);
await expect(page).toHaveURL(/\/Projects\/$/);
});
test("a failed move shows the server's reason and keeps the dialog", async ({ page }) => {
mkdirSync(join(wiki, "Taken"), { recursive: true });
writeFileSync(join(wiki, "Taken", "index.md"), "# Taken\n");
mkdirSync(join(wiki, "Mover"), { recursive: true });
writeFileSync(join(wiki, "Mover", "index.md"), "# Mover\n");
await page.goto("/Mover/");
await pageAction(page, "Move page (M)");
await modal(page).getByRole("button", { name: "NEXT" }).click();
await modal(page).getByPlaceholder("Page name").fill("Taken");
// Dismiss the alert from the handler: an open alert blocks the click.
const messages = [];
page.on("dialog", (d) => {
messages.push(d.message());
d.dismiss();
});
await modal(page).getByRole("button", { name: "MOVE" }).click();
await expect.poll(() => messages).toEqual([expect.stringContaining("destination already exists")]);
await expect(modal(page)).toBeVisible();
await expect(page).toHaveURL(/\/Mover\/$/);
});