fix(hk): protect unsaved editors and await prefix save results
CI / check (push) Successful in 2m34s
CI / deploy (push) Successful in 19s
CI / publish-container (push) Successful in 1m24s

This commit is contained in:
Simo committed 2026-09-11 10:48:34 +02:00
1 parent 89af4eb1f6
commit d5091d1a73
11 files changed
+602 -231

No files matched your search

+1
View File
@@ -197,6 +197,7 @@ const server = createServer(async (req, res) => {
}
if (
[
"/admin/unsaved-harness",
"/admin/attachment-harness",
"/admin/studio-harness",
"/admin/jobs-harness",
+4 -1
View File
@@ -16,6 +16,7 @@ import messages from "@/messages/en.json";
import { AttachmentHarness } from "./attachment-harness";
import { FurnitureJobsHarness } from "./furniture-jobs-harness";
import { fixtureAction } from "./server-actions";
import { UnsavedHarness } from "./unsaved-harness";
const theme = document.createElement("style");
theme.textContent = themePaletteCss(
@@ -105,7 +106,9 @@ createRoot(root).render(
? "News editor"
: "Furniture synchronization"}
</h1>
{route.includes("studio-harness") ? (
{route.includes("unsaved-harness") ? (
<UnsavedHarness />
) : route.includes("studio-harness") ? (
<StudioClient
source={buildFurniImportSource("it")}
initialTree={[]}
+70
View File
@@ -0,0 +1,70 @@
import { useState } from "react";
import { PrefixDialog } from "@/app/admin/prefixes/prefix-dialog";
import {
confirmUnsavedNavigation,
useUnsavedChanges,
} from "@/hooks/use-unsaved-changes";
export function UnsavedHarness() {
const [value, setValue] = useState("");
const [open, setOpen] = useState(false);
const [failSave, setFailSave] = useState(false);
const [saved, setSaved] = useState(false);
const [navigated, setNavigated] = useState(false);
const guard = useUnsavedChanges(value !== "", "Discard unsaved changes?");
return (
<>
<label>
Working value
<input
aria-label="Working value"
value={value}
onChange={(event) => setValue(event.target.value)}
/>
</label>
<a href="/admin/unsaved-destination">Leave editor</a>
<a href="#same-page">Same page</a>
<a href="mailto:[email protected]">Email</a>
<button
type="button"
onClick={() => {
if (confirmUnsavedNavigation()) setNavigated(true);
}}
>
Programmatic navigation
</button>
<button
type="button"
onClick={() => {
guard.markSaved();
setValue("");
setSaved(true);
}}
>
Save working value
</button>
{saved && <p>Working value saved</p>}
{navigated && <p>Navigation allowed</p>}
<button type="button" onClick={() => setOpen(true)}>
Open prefix
</button>
<label>
<input
type="checkbox"
checked={failSave}
onChange={(event) => setFailSave(event.target.checked)}
/>
Fail prefix save
</label>
<PrefixDialog
isOpen={open}
onClose={() => setOpen(false)}
editPrefix={null}
onSave={async () => {
if (failSave) return false;
setSaved(true);
return true;
}}
/>
</>
);
}
+117
View File
@@ -0,0 +1,117 @@
import { expect, test } from "@playwright/test";
test.beforeEach(async ({ page }) => {
await page.goto("/admin/unsaved-harness");
});
test("cancelled programmatic navigation preserves changes and save clears protection", async ({
page,
}) => {
await page.getByLabel("Working value").fill("Keep this");
page.once("dialog", (dialog) => dialog.dismiss());
await page.getByRole("button", { name: "Programmatic navigation" }).click();
await expect(page.getByText("Navigation allowed")).toHaveCount(0);
await expect(page.getByLabel("Working value")).toHaveValue("Keep this");
await page.getByRole("button", { name: "Save working value" }).click();
let prompts = 0;
page.on("dialog", async (dialog) => {
prompts++;
await dialog.dismiss();
});
await page.getByRole("button", { name: "Programmatic navigation" }).click();
await expect(page.getByText("Navigation allowed")).toBeVisible();
expect(prompts).toBe(0);
});
test("cancelled link navigation and same-page anchors preserve the editor", async ({
page,
}) => {
await page.getByLabel("Working value").fill("Keep this");
let prompts = 0;
page.on("dialog", async (dialog) => {
prompts++;
await dialog.dismiss();
});
await page.getByRole("link", { name: "Leave editor" }).click();
await expect(page).toHaveURL(/unsaved-harness$/);
await page.getByRole("link", { name: "Same page" }).click();
await expect(page).toHaveURL(/#same-page$/);
expect(prompts).toBe(1);
});
test("prefix cancel and Escape preserve edits; accepting discard resets reopening", async ({
page,
}) => {
await page.getByRole("button", { name: "Open prefix" }).click();
await page
.getByRole("textbox", { name: "Prefix text", exact: true })
.fill("VIP");
page.once("dialog", (dialog) => dialog.dismiss());
await page.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(
page.getByRole("textbox", { name: "Prefix text", exact: true }),
).toHaveValue("VIP");
page.once("dialog", (dialog) => dialog.dismiss());
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog")).toBeVisible();
page.once("dialog", (dialog) => dialog.accept());
await page.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(page.getByRole("dialog")).toHaveCount(0);
await page.getByRole("button", { name: "Open prefix" }).click();
await expect(
page.getByRole("textbox", { name: "Prefix text", exact: true }),
).toHaveValue("");
});
test("successful prefix save closes without a discard prompt", async ({
page,
}) => {
await page.getByRole("button", { name: "Open prefix" }).click();
await page
.getByRole("textbox", { name: "Username", exact: true })
.fill("Alice");
await page
.getByRole("textbox", { name: "Prefix text", exact: true })
.fill("VIP");
let prompts = 0;
page.on("dialog", async (dialog) => {
prompts++;
await dialog.dismiss();
});
await page.getByRole("button", { name: "Save", exact: true }).click();
await expect(page.getByRole("dialog")).toHaveCount(0);
expect(prompts).toBe(0);
});
test("supported browser back can be cancelled without changing the address or draft", async ({
page,
}) => {
test.skip(
!(await page.evaluate(() => "navigation" in window)),
"Navigation API unavailable",
);
await page.evaluate(() =>
history.pushState({}, "", "/admin/unsaved-harness?step=2"),
);
await page.getByLabel("Working value").fill("Keep this");
const prompt = page.waitForEvent("dialog");
await page.evaluate(() => history.back());
await (await prompt).dismiss();
await expect(page).toHaveURL(/step=2$/);
await expect(page.getByLabel("Working value")).toHaveValue("Keep this");
});
test("failed prefix save keeps the editor and dirty protection", async ({
page,
}) => {
await page.getByLabel("Fail prefix save").check();
await page.getByRole("button", { name: "Open prefix" }).click();
await page
.getByRole("textbox", { name: "Username", exact: true })
.fill("Alice");
await page
.getByRole("textbox", { name: "Prefix text", exact: true })
.fill("VIP");
await page.getByRole("button", { name: "Save", exact: true }).click();
await expect(page.getByRole("dialog")).toBeVisible();
page.once("dialog", (dialog) => dialog.dismiss());
await page.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(
page.getByRole("textbox", { name: "Prefix text", exact: true }),
).toHaveValue("VIP");
});