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");
});
@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={requestOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
@@ -78,7 +98,7 @@ export function BadgeEditorDialog({
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="space-y-4">
<fieldset disabled={saving} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="badge-name">Name</Label>
<Input
@@ -97,13 +117,13 @@ export function BadgeEditorDialog({
placeholder="Badge description"
/>
</div>
</div>
</fieldset>
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
onClick={() => requestOpenChange(false)}
disabled={saving}
>
Cancel
+184 -143
View File
@@ -1,9 +1,11 @@
"use client";
import { Save, X } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { useUnsavedChanges } from "@/hooks/use-unsaved-changes";
import {
PRESET_EFFECTS,
PRESET_ICONS,
@@ -18,7 +20,7 @@ export function PrefixDialog({
}: {
isOpen: boolean;
onClose: () => void;
onSave: (data: PrefixFormData, editId?: number) => Promise<void>;
onSave: (data: PrefixFormData, editId?: number) => Promise<boolean>;
editPrefix: UserPrefix | null;
}) {
const [form, setForm] = useState<PrefixFormData>({
@@ -30,9 +32,33 @@ export function PrefixDialog({
active: false,
});
const [saving, setSaving] = useState(false);
const savingRef = useRef(false);
const [saveError, setSaveError] = useState(false);
const tAction = useTranslations("pages.admin.actions");
const initial: PrefixFormData = {
username: editPrefix?.username || "",
text: editPrefix?.text || "",
color: editPrefix?.color || "#FFFFFF",
icon: editPrefix?.icon || "",
effect: editPrefix?.effect || "",
active: editPrefix?.active ?? false,
};
const dirty = Object.keys(initial).some(
(key) =>
form[key as keyof PrefixFormData] !==
initial[key as keyof PrefixFormData],
);
const { confirmLeave, markSaved } = useUnsavedChanges(
isOpen && dirty,
tAction("leaveUnsaved"),
);
function requestClose() {
if (!saving && confirmLeave()) onClose();
}
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => {
setSaveError(false);
if (editPrefix) {
setForm({
username: editPrefix.username || "",
@@ -57,11 +83,21 @@ export function PrefixDialog({
if (!isOpen) return null;
const handleSave = async () => {
if (savingRef.current) return;
savingRef.current = true;
setSaveError(false);
setSaving(true);
try {
await onSave(form, editPrefix?.id);
if (!(await onSave(form, editPrefix?.id))) {
setSaveError(true);
return;
}
markSaved();
onClose();
} catch {
setSaveError(true);
} finally {
savingRef.current = false;
setSaving(false);
}
};
@@ -74,7 +110,7 @@ export function PrefixDialog({
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open && !saving) onClose();
if (!open) requestClose();
}}
disablePointerDismissal
>
@@ -82,157 +118,162 @@ export function PrefixDialog({
showCloseButton={false}
className="block max-h-[90dvh] overflow-y-auto sm:max-w-md space-y-4"
>
<div className="flex items-center justify-between">
<DialogTitle className="text-lg font-semibold">
{editPrefix ? "Edit Prefix" : "Add Prefix"}
</DialogTitle>
<button
type="button"
disabled={saving}
onClick={onClose}
className="text-muted-foreground hover:text-foreground"
>
<span className="sr-only">Close prefix editor</span>
<X className="h-5 w-5" />
</button>
</div>
<fieldset disabled={saving} className="contents">
{saveError && <p role="alert">{tAction("saveError")}</p>}
<div className="flex items-center justify-between">
<DialogTitle className="text-lg font-semibold">
{editPrefix ? "Edit Prefix" : "Add Prefix"}
</DialogTitle>
<button
type="button"
disabled={saving}
onClick={requestClose}
className="text-muted-foreground hover:text-foreground"
>
<span className="sr-only">Close prefix editor</span>
<X className="h-5 w-5" />
</button>
</div>
{/* Preview */}
<div className="flex items-center justify-center p-3 rounded-lg bg-muted min-h-[40px]">
<span className="text-lg font-bold">
{form.icon && <span className="mr-0.5">{form.icon}</span>}
{hasMultiColor ? (
<span>
{"{"}
{[...(form.text || "...")]
.map((char, i) => ({ char, n: i }))
.map(({ char, n }) => (
<span
key={`char-${n}`}
style={{ color: colors[Math.min(n, colors.length - 1)] }}
>
{char}
</span>
))}
{"}"}
</span>
) : (
<span style={{ color: colors[0] || "#FFFFFF" }}>
{"{"}
{form.text || "..."}
{"}"}
</span>
)}
</span>
<span className="ml-2 text-lg text-muted-foreground">
{form.username || "Username"}
</span>
</div>
{/* Preview */}
<div className="flex items-center justify-center p-3 rounded-lg bg-muted min-h-[40px]">
<span className="text-lg font-bold">
{form.icon && <span className="mr-0.5">{form.icon}</span>}
{hasMultiColor ? (
<span>
{"{"}
{[...(form.text || "...")]
.map((char, i) => ({ char, n: i }))
.map(({ char, n }) => (
<span
key={`char-${n}`}
style={{
color: colors[Math.min(n, colors.length - 1)],
}}
>
{char}
</span>
))}
{"}"}
</span>
) : (
<span style={{ color: colors[0] || "#FFFFFF" }}>
{"{"}
{form.text || "..."}
{"}"}
</span>
)}
</span>
<span className="ml-2 text-lg text-muted-foreground">
{form.username || "Username"}
</span>
</div>
{!editPrefix && (
<div className="space-y-2">
<span className="text-sm font-medium">Username</span>
<Input
placeholder="Username..."
aria-label="Username"
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
/>
</div>
)}
{!editPrefix && (
<div className="space-y-2">
<span className="text-sm font-medium">Username</span>
<span className="text-sm font-medium">Text</span>
<Input
placeholder="Username..."
aria-label="Username"
value={form.username}
onChange={(e) => 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 })}
/>
<span className="text-xs text-muted-foreground">
{form.text.length}/15
</span>
</div>
)}
<div className="space-y-2">
<span className="text-sm font-medium">Text</span>
<Input
maxLength={15}
placeholder="Prefix text..."
aria-label="Prefix text"
value={form.text}
onChange={(e) => setForm({ ...form, text: e.target.value })}
/>
<span className="text-xs text-muted-foreground">
{form.text.length}/15
</span>
</div>
<div className="space-y-2">
<span className="text-sm font-medium">
Color (HEX or multi: #FF0000,#00FF00)
</span>
<div className="flex items-center gap-2">
<Input
placeholder="#FFFFFF o #FF0000,#00FF00,..."
aria-label="Prefix color"
value={form.color}
onChange={(e) => setForm({ ...form, color: e.target.value })}
/>
<div
className="w-8 h-8 rounded border shrink-0"
style={{ backgroundColor: colors[0] || "#FFFFFF" }}
/>
</div>
</div>
<div className="space-y-2">
<span className="text-sm font-medium">Icon</span>
<select
className="w-full px-3 py-2 rounded border bg-background text-sm"
aria-label="Prefix icon"
value={form.icon}
onChange={(e) => setForm({ ...form, icon: e.target.value })}
>
{PRESET_ICONS.map((icon) => (
<option key={icon.label} value={icon.emoji}>
{icon.emoji ? `${icon.emoji} ${icon.label}` : icon.label}
</option>
))}
</select>
</div>
<div className="space-y-2">
<span className="text-sm font-medium">Effect</span>
<select
className="w-full px-3 py-2 rounded border bg-background text-sm"
aria-label="Prefix effect"
value={form.effect}
onChange={(e) => setForm({ ...form, effect: e.target.value })}
>
{PRESET_EFFECTS.map((fx) => (
<option key={fx.id} value={fx.id}>
{fx.label}
</option>
))}
</select>
</div>
<div className="space-y-2">
<span className="text-sm font-medium">
Color (HEX or multi: #FF0000,#00FF00)
</span>
<div className="flex items-center gap-2">
<Input
placeholder="#FFFFFF o #FF0000,#00FF00,..."
aria-label="Prefix color"
value={form.color}
onChange={(e) => setForm({ ...form, color: e.target.value })}
/>
<div
className="w-8 h-8 rounded border shrink-0"
style={{ backgroundColor: colors[0] || "#FFFFFF" }}
<input
checked={form.active}
id="prefix-active"
type="checkbox"
onChange={(e) => setForm({ ...form, active: e.target.checked })}
/>
<label className="text-sm" htmlFor="prefix-active">
Active
</label>
</div>
</div>
<div className="space-y-2">
<span className="text-sm font-medium">Icon</span>
<select
className="w-full px-3 py-2 rounded border bg-background text-sm"
aria-label="Prefix icon"
value={form.icon}
onChange={(e) => setForm({ ...form, icon: e.target.value })}
>
{PRESET_ICONS.map((icon) => (
<option key={icon.label} value={icon.emoji}>
{icon.emoji ? `${icon.emoji} ${icon.label}` : icon.label}
</option>
))}
</select>
</div>
<div className="space-y-2">
<span className="text-sm font-medium">Effect</span>
<select
className="w-full px-3 py-2 rounded border bg-background text-sm"
aria-label="Prefix effect"
value={form.effect}
onChange={(e) => setForm({ ...form, effect: e.target.value })}
>
{PRESET_EFFECTS.map((fx) => (
<option key={fx.id} value={fx.id}>
{fx.label}
</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<input
checked={form.active}
id="prefix-active"
type="checkbox"
onChange={(e) => setForm({ ...form, active: e.target.checked })}
/>
<label className="text-sm" htmlFor="prefix-active">
Active
</label>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button
disabled={
saving ||
!form.text.trim() ||
(!editPrefix && !form.username.trim())
}
onClick={handleSave}
>
<Save className="h-4 w-4 mr-1" />
{saving ? "Saving..." : "Save"}
</Button>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={requestClose}>
Cancel
</Button>
<Button
disabled={
saving ||
!form.text.trim() ||
(!editPrefix && !form.username.trim())
}
onClick={handleSave}
>
<Save className="h-4 w-4 mr-1" />
{saving ? "Saving..." : "Save"}
</Button>
</div>
</fieldset>
</DialogContent>
</Dialog>
);
+21 -31
View File
@@ -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<boolean> => {
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 = () => {
@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={requestOpenChange}>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>Edit furniture</DialogTitle>
@@ -86,7 +108,7 @@ export function EditItemDialog({ roomId, item, open, onOpenChange }: Props) {
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<fieldset disabled={isPending} className="space-y-4">
{isWall ? (
<div className="space-y-2">
<Label htmlFor="wallPos">Wall position</Label>
@@ -165,12 +187,12 @@ export function EditItemDialog({ roomId, item, open, onOpenChange }: Props) {
Item state/data (e.g. dice value, sticky note text, color)
</p>
</div>
</div>
</fieldset>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
onClick={() => requestOpenChange(false)}
disabled={isPending}
>
Cancel
+2
View File
@@ -21,6 +21,7 @@ import {
DialogTrigger,
} from "@/components/ui/dialog";
import { useDebounce } from "@/hooks/use-debounce";
import { confirmUnsavedNavigation } from "@/hooks/use-unsaved-changes";
import {
type AdminNavigationOptions,
visibleAdminNavigation,
@@ -152,6 +153,7 @@ export function SearchDialog({
}
function handleSelect(url: string) {
if (!confirmUnsavedNavigation()) return;
handleOpenChange(false);
setQuery("");
setResults([]);
+57
View File
@@ -0,0 +1,57 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
const cleanup = vi.hoisted(() => [] as Array<() => void>);
vi.mock("react", () => ({
useRef: (value: unknown) => ({ current: value }),
useCallback: (callback: unknown) => callback,
useEffect: (effect: () => (() => void) | undefined) => {
const dispose = effect();
if (dispose) cleanup.push(dispose);
},
}));
import {
confirmUnsavedNavigation,
useUnsavedChanges,
} from "./use-unsaved-changes";
let confirm: ReturnType<typeof vi.fn>;
beforeEach(() => {
confirm = vi.fn().mockReturnValue(false);
vi.stubGlobal("window", Object.assign(new EventTarget(), { confirm }));
vi.stubGlobal("document", new EventTarget());
vi.stubGlobal("location", new URL("https://hotel.test/admin/editor"));
});
afterEach(() => {
for (const dispose of cleanup.splice(0)) dispose();
vi.unstubAllGlobals();
});
it("asks once for several dirty forms and allows clean navigation", () => {
useUnsavedChanges(true, "Discard changes?");
useUnsavedChanges(true, "Other changes?");
expect(confirmUnsavedNavigation()).toBe(false);
expect(confirm).toHaveBeenCalledTimes(1);
confirm.mockReturnValue(true);
expect(confirmUnsavedNavigation()).toBe(true);
});
it("removes unmounted forms from navigation protection", () => {
useUnsavedChanges(true, "Discard changes?");
for (const dispose of cleanup.splice(0)) dispose();
expect(confirmUnsavedNavigation()).toBe(true);
expect(confirm).not.toHaveBeenCalled();
});
it("protects explicit close but clears synchronously after successful save", () => {
const guard = useUnsavedChanges(true, "Discard changes?");
expect(guard.confirmLeave()).toBe(false);
guard.markSaved();
expect(guard.confirmLeave()).toBe(true);
expect(confirmUnsavedNavigation()).toBe(true);
expect(confirm).toHaveBeenCalledTimes(1);
});
it("keeps reload protected after cancelled navigation", () => {
useUnsavedChanges(true, "Discard changes?");
confirmUnsavedNavigation();
const event = new Event("beforeunload", { cancelable: true });
window.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
});
+90 -42
View File
@@ -1,50 +1,98 @@
"use client";
import { useEffect } from "react";
/** Protect browser close/reload and same-tab link navigation while editing. */
import { useCallback, useEffect, useRef } from "react";
interface Guard {
dirty: boolean;
message: string;
}
const guards = new Set<{ current: Guard }>();
/** Call before programmatic navigation that would replace an unsaved form. */
export function confirmUnsavedNavigation(): boolean {
const guard = [...guards].find((entry) => entry.current.dirty)?.current;
return !guard || window.confirm(guard.message);
}
function leavesCurrentPage(href: string) {
const target = new URL(href, location.href);
return (
(target.protocol === "http:" || target.protocol === "https:") &&
(target.origin !== location.origin ||
target.pathname !== location.pathname ||
target.search !== location.search)
);
}
function beforeUnload(event: BeforeUnloadEvent) {
if (![...guards].some((entry) => entry.current.dirty)) return;
event.preventDefault();
event.returnValue = "";
}
function click(event: MouseEvent) {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey ||
event.altKey
)
return;
const link =
event.target instanceof Element ? event.target.closest("a[href]") : null;
if (
!(link instanceof HTMLAnchorElement) ||
link.hasAttribute("download") ||
(link.target && link.target !== "_self") ||
!leavesCurrentPage(link.href)
)
return;
if (!confirmUnsavedNavigation()) {
event.preventDefault();
event.stopImmediatePropagation();
}
}
interface HistoryNavigationEvent extends Event {
navigationType: string;
destination: { url: string };
}
function traverse(event: Event) {
const navigation = event as HistoryNavigationEvent;
if (
navigation.navigationType !== "traverse" ||
!event.cancelable ||
!leavesCurrentPage(navigation.destination.url)
)
return;
if (!confirmUnsavedNavigation()) event.preventDefault();
}
/** Protect reload, links and supported browser history traversals; expose explicit close protection. */
export function useUnsavedChanges(dirty: boolean, message: string) {
const guard = useRef({ dirty, message });
guard.current = { dirty, message };
useEffect(() => {
if (!dirty) return;
function beforeUnload(event: BeforeUnloadEvent) {
event.preventDefault();
event.returnValue = "";
const entry = guard;
const navigation = (window as Window & { navigation?: EventTarget })
.navigation;
if (guards.size === 0) {
window.addEventListener("beforeunload", beforeUnload);
document.addEventListener("click", click, true);
navigation?.addEventListener("navigate", traverse);
}
function click(event: MouseEvent) {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey ||
event.altKey
)
return;
const link =
event.target instanceof Element
? event.target.closest("a[href]")
: null;
if (
!(link instanceof HTMLAnchorElement) ||
link.hasAttribute("download") ||
(link.target && link.target !== "_self")
)
return;
const target = new URL(link.href, location.href);
if (
target.origin === location.origin &&
target.pathname === location.pathname &&
target.search === location.search
)
return;
if (!window.confirm(message)) {
event.preventDefault();
event.stopPropagation();
}
}
window.addEventListener("beforeunload", beforeUnload);
document.addEventListener("click", click, true);
guards.add(entry);
return () => {
window.removeEventListener("beforeunload", beforeUnload);
document.removeEventListener("click", click, true);
guards.delete(entry);
if (guards.size === 0) {
window.removeEventListener("beforeunload", beforeUnload);
document.removeEventListener("click", click, true);
navigation?.removeEventListener("navigate", traverse);
}
};
}, [dirty, message]);
}, [dirty]);
const confirmLeave = useCallback(
() => !guard.current.dirty || window.confirm(guard.current.message),
[],
);
const markSaved = useCallback(() => {
guard.current.dirty = false;
}, []);
return { confirmLeave, markSaved };
}