diff --git a/e2e/ui/fixture-server.mjs b/e2e/ui/fixture-server.mjs index 52f44062..547d9f6c 100644 --- a/e2e/ui/fixture-server.mjs +++ b/e2e/ui/fixture-server.mjs @@ -197,6 +197,7 @@ const server = createServer(async (req, res) => { } if ( [ + "/admin/unsaved-harness", "/admin/attachment-harness", "/admin/studio-harness", "/admin/jobs-harness", diff --git a/e2e/ui/fixtures/entry.tsx b/e2e/ui/fixtures/entry.tsx index 1dcac4a1..17d0f77a 100644 --- a/e2e/ui/fixtures/entry.tsx +++ b/e2e/ui/fixtures/entry.tsx @@ -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"} - {route.includes("studio-harness") ? ( + {route.includes("unsaved-harness") ? ( + + ) : route.includes("studio-harness") ? ( + + Leave editor + Same page + Email + + + {saved &&

Working value saved

} + {navigated &&

Navigation allowed

} + + + setOpen(false)} + editPrefix={null} + onSave={async () => { + if (failSave) return false; + setSaved(true); + return true; + }} + /> + + ); +} diff --git a/e2e/ui/unsaved-changes.spec.ts b/e2e/ui/unsaved-changes.spec.ts new file mode 100644 index 00000000..020feede --- /dev/null +++ b/e2e/ui/unsaved-changes.spec.ts @@ -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"); +}); diff --git a/src/app/admin/import/badges/badge-editor-dialog.tsx b/src/app/admin/import/badges/badge-editor-dialog.tsx index dfdc7dcb..3f8bedc6 100644 --- a/src/app/admin/import/badges/badge-editor-dialog.tsx +++ b/src/app/admin/import/badges/badge-editor-dialog.tsx @@ -1,6 +1,7 @@ "use client"; import { Loader2 } from "lucide-react"; +import { useTranslations } from "next-intl"; import { useEffect, useState } from "react"; import { toast } from "sonner"; import { getBadgeData, updateBadge } from "@/actions/badges"; @@ -15,6 +16,7 @@ import { import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useServerAction } from "@/hooks/use-server-action"; +import { useUnsavedChanges } from "@/hooks/use-unsaved-changes"; interface BadgeEditorDialogProps { code: string; @@ -34,23 +36,39 @@ export function BadgeEditorDialog({ const [desc, setDesc] = useState(""); const [loading, setLoading] = useState(false); const { run, isPending: saving } = useServerAction(); + const [saved, setSaved] = useState({ name: "", desc: "" }); + const tAction = useTranslations("pages.admin.actions"); + const { confirmLeave, markSaved } = useUnsavedChanges( + open && !loading && (name !== saved.name || desc !== saved.desc), + tAction("leaveUnsaved"), + ); + function requestOpenChange(next: boolean) { + if (next || (!saving && confirmLeave())) onOpenChange(next); + } // Fetch current values when dialog opens useEffect(() => { if (!open || !code) return; + let active = true; setLoading(true); getBadgeData({ code }) .then((result) => { - if (result.ok) { + if (active && result.ok) { const data = result.data as { name: string; desc: string }; setName(data.name ?? ""); setDesc(data.desc ?? ""); + setSaved({ name: data.name ?? "", desc: data.desc ?? "" }); } }) .catch(() => { - toast.error("Failed to load badge data"); + if (active) toast.error("Failed to load badge data"); }) - .finally(() => setLoading(false)); + .finally(() => { + if (active) setLoading(false); + }); + return () => { + active = false; + }; }, [open, code]); function handleSave() { @@ -58,6 +76,8 @@ export function BadgeEditorDialog({ successMessage: `Badge ${code} updated`, errorMessage: "Save failed", onSuccess: () => { + markSaved(); + setSaved({ name, desc }); onSaved?.(); onOpenChange(false); }, @@ -65,7 +85,7 @@ export function BadgeEditorDialog({ } return ( - + @@ -78,7 +98,7 @@ export function BadgeEditorDialog({ ) : ( -
+
-
+ )} - +
+ {saveError &&

{tAction("saveError")}

} +
+ + {editPrefix ? "Edit Prefix" : "Add Prefix"} + + +
- {/* Preview */} -
- - {form.icon && {form.icon}} - {hasMultiColor ? ( - - {"{"} - {[...(form.text || "...")] - .map((char, i) => ({ char, n: i })) - .map(({ char, n }) => ( - - {char} - - ))} - {"}"} - - ) : ( - - {"{"} - {form.text || "..."} - {"}"} - - )} - - - {form.username || "Username"} - -
+ {/* Preview */} +
+ + {form.icon && {form.icon}} + {hasMultiColor ? ( + + {"{"} + {[...(form.text || "...")] + .map((char, i) => ({ char, n: i })) + .map(({ char, n }) => ( + + {char} + + ))} + {"}"} + + ) : ( + + {"{"} + {form.text || "..."} + {"}"} + + )} + + + {form.username || "Username"} + +
+ + {!editPrefix && ( +
+ Username + setForm({ ...form, username: e.target.value })} + /> +
+ )} - {!editPrefix && (
- Username + Text setForm({ ...form, username: e.target.value })} + maxLength={15} + placeholder="Prefix text..." + aria-label="Prefix text" + value={form.text} + onChange={(e) => setForm({ ...form, text: e.target.value })} /> + + {form.text.length}/15 +
- )} -
- Text - setForm({ ...form, text: e.target.value })} - /> - - {form.text.length}/15 - -
+
+ + Color (HEX or multi: #FF0000,#00FF00) + +
+ setForm({ ...form, color: e.target.value })} + /> +
+
+
+ +
+ Icon + +
+ +
+ Effect + +
-
- - Color (HEX or multi: #FF0000,#00FF00) -
- setForm({ ...form, color: e.target.value })} - /> -
setForm({ ...form, active: e.target.checked })} /> +
-
-
- Icon - -
- -
- Effect - -
- -
- setForm({ ...form, active: e.target.checked })} - /> - -
- -
- - -
+
+ + +
+
); diff --git a/src/app/admin/prefixes/prefixes-client.tsx b/src/app/admin/prefixes/prefixes-client.tsx index 684684ae..c9703eda 100644 --- a/src/app/admin/prefixes/prefixes-client.tsx +++ b/src/app/admin/prefixes/prefixes-client.tsx @@ -107,44 +107,34 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) { }); }; - const handleSavePrefix = async (data: PrefixFormData, editId?: number) => { - if (editId) { + const handleSavePrefix = ( + data: PrefixFormData, + editId?: number, + ): Promise => { + if (isPending) return Promise.resolve(false); + return new Promise((resolve) => { + const fields = { + text: data.text, + color: data.color, + icon: data.icon, + effect: data.effect, + active: data.active ? 1 : 0, + }; run( () => - updatePrefix({ - id: editId, - text: data.text, - color: data.color, - icon: data.icon, - effect: data.effect, - active: data.active ? 1 : 0, - }), + editId + ? updatePrefix({ id: editId, ...fields }) + : createPrefix({ username: data.username, ...fields }), { - successMessage: "Prefix updated!", + successMessage: editId ? "Prefix updated!" : "Prefix created!", onSuccess: () => { - fetchPrefixes(); + void fetchPrefixes(); + resolve(true); }, + onError: () => resolve(false), }, ); - } else { - run( - () => - createPrefix({ - username: data.username, - text: data.text, - color: data.color, - icon: data.icon, - effect: data.effect, - active: data.active ? 1 : 0, - }), - { - successMessage: "Prefix created!", - onSuccess: () => { - fetchPrefixes(); - }, - }, - ); - } + }); }; const openAddDialog = () => { diff --git a/src/app/admin/rooms/[id]/furni/edit-item-dialog.tsx b/src/app/admin/rooms/[id]/furni/edit-item-dialog.tsx index 79fb5464..acff4553 100644 --- a/src/app/admin/rooms/[id]/furni/edit-item-dialog.tsx +++ b/src/app/admin/rooms/[id]/furni/edit-item-dialog.tsx @@ -1,5 +1,6 @@ "use client"; +import { useTranslations } from "next-intl"; import { useEffect, useState } from "react"; import { updateRoomItem } from "@/actions/rooms"; import { Button } from "@/components/ui/button"; @@ -15,6 +16,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { useServerAction } from "@/hooks/use-server-action"; +import { useUnsavedChanges } from "@/hooks/use-unsaved-changes"; interface EditItem { id: number; @@ -45,9 +47,26 @@ export function EditItemDialog({ roomId, item, open, onOpenChange }: Props) { const [extraData, setExtraData] = useState(""); const isWall = item?.type === "i"; + const tAction = useTranslations("pages.admin.actions"); + const dirty = + !!item && + (extraData !== item.extraData || + (isWall + ? wallPos !== item.wallPos + : x !== String(item.x) || + y !== String(item.y) || + z !== String(item.z) || + rot !== String(item.rot))); + const { confirmLeave, markSaved } = useUnsavedChanges( + open && dirty, + tAction("leaveUnsaved"), + ); + function requestOpenChange(next: boolean) { + if (next || (!isPending && confirmLeave())) onOpenChange(next); + } useEffect(() => { - if (item) { + if (item && open) { setX(String(item.x)); setY(String(item.y)); setZ(String(item.z)); @@ -55,7 +74,7 @@ export function EditItemDialog({ roomId, item, open, onOpenChange }: Props) { setWallPos(item.wallPos); setExtraData(item.extraData); } - }, [item]); + }, [item, open]); function handleSave() { if (!item) return; @@ -72,12 +91,15 @@ export function EditItemDialog({ roomId, item, open, onOpenChange }: Props) { }; run(() => updateRoomItem(payload), { successMessage: "Item updated and room reloaded", - onSuccess: () => onOpenChange(false), + onSuccess: () => { + markSaved(); + onOpenChange(false); + }, }); } return ( - + Edit furniture @@ -86,7 +108,7 @@ export function EditItemDialog({ roomId, item, open, onOpenChange }: Props) { -
+
{isWall ? (
@@ -165,12 +187,12 @@ export function EditItemDialog({ roomId, item, open, onOpenChange }: Props) { Item state/data (e.g. dice value, sticky note text, color)

-
+