fix(hk): protect unsaved editors and await prefix save results
This commit is contained in:
1 parent
89af4eb1f6
commit
d5091d1a73
11 files changed
+602
-231
No files matched your search
@@ -197,6 +197,7 @@ const server = createServer(async (req, res) => {
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
[
|
[
|
||||||
|
"/admin/unsaved-harness",
|
||||||
"/admin/attachment-harness",
|
"/admin/attachment-harness",
|
||||||
"/admin/studio-harness",
|
"/admin/studio-harness",
|
||||||
"/admin/jobs-harness",
|
"/admin/jobs-harness",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import messages from "@/messages/en.json";
|
|||||||
import { AttachmentHarness } from "./attachment-harness";
|
import { AttachmentHarness } from "./attachment-harness";
|
||||||
import { FurnitureJobsHarness } from "./furniture-jobs-harness";
|
import { FurnitureJobsHarness } from "./furniture-jobs-harness";
|
||||||
import { fixtureAction } from "./server-actions";
|
import { fixtureAction } from "./server-actions";
|
||||||
|
import { UnsavedHarness } from "./unsaved-harness";
|
||||||
|
|
||||||
const theme = document.createElement("style");
|
const theme = document.createElement("style");
|
||||||
theme.textContent = themePaletteCss(
|
theme.textContent = themePaletteCss(
|
||||||
@@ -105,7 +106,9 @@ createRoot(root).render(
|
|||||||
? "News editor"
|
? "News editor"
|
||||||
: "Furniture synchronization"}
|
: "Furniture synchronization"}
|
||||||
</h1>
|
</h1>
|
||||||
{route.includes("studio-harness") ? (
|
{route.includes("unsaved-harness") ? (
|
||||||
|
<UnsavedHarness />
|
||||||
|
) : route.includes("studio-harness") ? (
|
||||||
<StudioClient
|
<StudioClient
|
||||||
source={buildFurniImportSource("it")}
|
source={buildFurniImportSource("it")}
|
||||||
initialTree={[]}
|
initialTree={[]}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
"use client";
|
||||||
|
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { getBadgeData, updateBadge } from "@/actions/badges";
|
import { getBadgeData, updateBadge } from "@/actions/badges";
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { useServerAction } from "@/hooks/use-server-action";
|
import { useServerAction } from "@/hooks/use-server-action";
|
||||||
|
import { useUnsavedChanges } from "@/hooks/use-unsaved-changes";
|
||||||
|
|
||||||
interface BadgeEditorDialogProps {
|
interface BadgeEditorDialogProps {
|
||||||
code: string;
|
code: string;
|
||||||
@@ -34,23 +36,39 @@ export function BadgeEditorDialog({
|
|||||||
const [desc, setDesc] = useState("");
|
const [desc, setDesc] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const { run, isPending: saving } = useServerAction();
|
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
|
// Fetch current values when dialog opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !code) return;
|
if (!open || !code) return;
|
||||||
|
let active = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
getBadgeData({ code })
|
getBadgeData({ code })
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
if (result.ok) {
|
if (active && result.ok) {
|
||||||
const data = result.data as { name: string; desc: string };
|
const data = result.data as { name: string; desc: string };
|
||||||
setName(data.name ?? "");
|
setName(data.name ?? "");
|
||||||
setDesc(data.desc ?? "");
|
setDesc(data.desc ?? "");
|
||||||
|
setSaved({ name: data.name ?? "", desc: data.desc ?? "" });
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.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]);
|
}, [open, code]);
|
||||||
|
|
||||||
function handleSave() {
|
function handleSave() {
|
||||||
@@ -58,6 +76,8 @@ export function BadgeEditorDialog({
|
|||||||
successMessage: `Badge ${code} updated`,
|
successMessage: `Badge ${code} updated`,
|
||||||
errorMessage: "Save failed",
|
errorMessage: "Save failed",
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
markSaved();
|
||||||
|
setSaved({ name, desc });
|
||||||
onSaved?.();
|
onSaved?.();
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
},
|
},
|
||||||
@@ -65,7 +85,7 @@ export function BadgeEditorDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={requestOpenChange}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<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" />
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<fieldset disabled={saving} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="badge-name">Name</Label>
|
<Label htmlFor="badge-name">Name</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -97,13 +117,13 @@ export function BadgeEditorDialog({
|
|||||||
placeholder="Badge description"
|
placeholder="Badge description"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</fieldset>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => requestOpenChange(false)}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { Save, X } from "lucide-react";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { useUnsavedChanges } from "@/hooks/use-unsaved-changes";
|
||||||
import {
|
import {
|
||||||
PRESET_EFFECTS,
|
PRESET_EFFECTS,
|
||||||
PRESET_ICONS,
|
PRESET_ICONS,
|
||||||
@@ -18,7 +20,7 @@ export function PrefixDialog({
|
|||||||
}: {
|
}: {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSave: (data: PrefixFormData, editId?: number) => Promise<void>;
|
onSave: (data: PrefixFormData, editId?: number) => Promise<boolean>;
|
||||||
editPrefix: UserPrefix | null;
|
editPrefix: UserPrefix | null;
|
||||||
}) {
|
}) {
|
||||||
const [form, setForm] = useState<PrefixFormData>({
|
const [form, setForm] = useState<PrefixFormData>({
|
||||||
@@ -30,9 +32,33 @@ export function PrefixDialog({
|
|||||||
active: false,
|
active: false,
|
||||||
});
|
});
|
||||||
const [saving, setSaving] = useState(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
|
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setSaveError(false);
|
||||||
if (editPrefix) {
|
if (editPrefix) {
|
||||||
setForm({
|
setForm({
|
||||||
username: editPrefix.username || "",
|
username: editPrefix.username || "",
|
||||||
@@ -57,11 +83,21 @@ export function PrefixDialog({
|
|||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
|
if (savingRef.current) return;
|
||||||
|
savingRef.current = true;
|
||||||
|
setSaveError(false);
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await onSave(form, editPrefix?.id);
|
if (!(await onSave(form, editPrefix?.id))) {
|
||||||
|
setSaveError(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
markSaved();
|
||||||
onClose();
|
onClose();
|
||||||
|
} catch {
|
||||||
|
setSaveError(true);
|
||||||
} finally {
|
} finally {
|
||||||
|
savingRef.current = false;
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -74,7 +110,7 @@ export function PrefixDialog({
|
|||||||
<Dialog
|
<Dialog
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open && !saving) onClose();
|
if (!open) requestClose();
|
||||||
}}
|
}}
|
||||||
disablePointerDismissal
|
disablePointerDismissal
|
||||||
>
|
>
|
||||||
@@ -82,157 +118,162 @@ export function PrefixDialog({
|
|||||||
showCloseButton={false}
|
showCloseButton={false}
|
||||||
className="block max-h-[90dvh] overflow-y-auto sm:max-w-md space-y-4"
|
className="block max-h-[90dvh] overflow-y-auto sm:max-w-md space-y-4"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<fieldset disabled={saving} className="contents">
|
||||||
<DialogTitle className="text-lg font-semibold">
|
{saveError && <p role="alert">{tAction("saveError")}</p>}
|
||||||
{editPrefix ? "Edit Prefix" : "Add Prefix"}
|
<div className="flex items-center justify-between">
|
||||||
</DialogTitle>
|
<DialogTitle className="text-lg font-semibold">
|
||||||
<button
|
{editPrefix ? "Edit Prefix" : "Add Prefix"}
|
||||||
type="button"
|
</DialogTitle>
|
||||||
disabled={saving}
|
<button
|
||||||
onClick={onClose}
|
type="button"
|
||||||
className="text-muted-foreground hover:text-foreground"
|
disabled={saving}
|
||||||
>
|
onClick={requestClose}
|
||||||
<span className="sr-only">Close prefix editor</span>
|
className="text-muted-foreground hover:text-foreground"
|
||||||
<X className="h-5 w-5" />
|
>
|
||||||
</button>
|
<span className="sr-only">Close prefix editor</span>
|
||||||
</div>
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Preview */}
|
{/* Preview */}
|
||||||
<div className="flex items-center justify-center p-3 rounded-lg bg-muted min-h-[40px]">
|
<div className="flex items-center justify-center p-3 rounded-lg bg-muted min-h-[40px]">
|
||||||
<span className="text-lg font-bold">
|
<span className="text-lg font-bold">
|
||||||
{form.icon && <span className="mr-0.5">{form.icon}</span>}
|
{form.icon && <span className="mr-0.5">{form.icon}</span>}
|
||||||
{hasMultiColor ? (
|
{hasMultiColor ? (
|
||||||
<span>
|
<span>
|
||||||
{"{"}
|
{"{"}
|
||||||
{[...(form.text || "...")]
|
{[...(form.text || "...")]
|
||||||
.map((char, i) => ({ char, n: i }))
|
.map((char, i) => ({ char, n: i }))
|
||||||
.map(({ char, n }) => (
|
.map(({ char, n }) => (
|
||||||
<span
|
<span
|
||||||
key={`char-${n}`}
|
key={`char-${n}`}
|
||||||
style={{ color: colors[Math.min(n, colors.length - 1)] }}
|
style={{
|
||||||
>
|
color: colors[Math.min(n, colors.length - 1)],
|
||||||
{char}
|
}}
|
||||||
</span>
|
>
|
||||||
))}
|
{char}
|
||||||
{"}"}
|
</span>
|
||||||
</span>
|
))}
|
||||||
) : (
|
{"}"}
|
||||||
<span style={{ color: colors[0] || "#FFFFFF" }}>
|
</span>
|
||||||
{"{"}
|
) : (
|
||||||
{form.text || "..."}
|
<span style={{ color: colors[0] || "#FFFFFF" }}>
|
||||||
{"}"}
|
{"{"}
|
||||||
</span>
|
{form.text || "..."}
|
||||||
)}
|
{"}"}
|
||||||
</span>
|
</span>
|
||||||
<span className="ml-2 text-lg text-muted-foreground">
|
)}
|
||||||
{form.username || "Username"}
|
</span>
|
||||||
</span>
|
<span className="ml-2 text-lg text-muted-foreground">
|
||||||
</div>
|
{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">
|
<div className="space-y-2">
|
||||||
<span className="text-sm font-medium">Username</span>
|
<span className="text-sm font-medium">Text</span>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Username..."
|
maxLength={15}
|
||||||
aria-label="Username"
|
placeholder="Prefix text..."
|
||||||
value={form.username}
|
aria-label="Prefix text"
|
||||||
onChange={(e) => setForm({ ...form, username: e.target.value })}
|
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>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<span className="text-sm font-medium">Text</span>
|
<span className="text-sm font-medium">
|
||||||
<Input
|
Color (HEX or multi: #FF0000,#00FF00)
|
||||||
maxLength={15}
|
</span>
|
||||||
placeholder="Prefix text..."
|
<div className="flex items-center gap-2">
|
||||||
aria-label="Prefix text"
|
<Input
|
||||||
value={form.text}
|
placeholder="#FFFFFF o #FF0000,#00FF00,..."
|
||||||
onChange={(e) => setForm({ ...form, text: e.target.value })}
|
aria-label="Prefix color"
|
||||||
/>
|
value={form.color}
|
||||||
<span className="text-xs text-muted-foreground">
|
onChange={(e) => setForm({ ...form, color: e.target.value })}
|
||||||
{form.text.length}/15
|
/>
|
||||||
</span>
|
<div
|
||||||
</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">
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<input
|
||||||
placeholder="#FFFFFF o #FF0000,#00FF00,..."
|
checked={form.active}
|
||||||
aria-label="Prefix color"
|
id="prefix-active"
|
||||||
value={form.color}
|
type="checkbox"
|
||||||
onChange={(e) => setForm({ ...form, color: e.target.value })}
|
onChange={(e) => setForm({ ...form, active: e.target.checked })}
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="w-8 h-8 rounded border shrink-0"
|
|
||||||
style={{ backgroundColor: colors[0] || "#FFFFFF" }}
|
|
||||||
/>
|
/>
|
||||||
|
<label className="text-sm" htmlFor="prefix-active">
|
||||||
|
Active
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<span className="text-sm font-medium">Icon</span>
|
<Button variant="outline" onClick={requestClose}>
|
||||||
<select
|
Cancel
|
||||||
className="w-full px-3 py-2 rounded border bg-background text-sm"
|
</Button>
|
||||||
aria-label="Prefix icon"
|
<Button
|
||||||
value={form.icon}
|
disabled={
|
||||||
onChange={(e) => setForm({ ...form, icon: e.target.value })}
|
saving ||
|
||||||
>
|
!form.text.trim() ||
|
||||||
{PRESET_ICONS.map((icon) => (
|
(!editPrefix && !form.username.trim())
|
||||||
<option key={icon.label} value={icon.emoji}>
|
}
|
||||||
{icon.emoji ? `${icon.emoji} ${icon.label}` : icon.label}
|
onClick={handleSave}
|
||||||
</option>
|
>
|
||||||
))}
|
<Save className="h-4 w-4 mr-1" />
|
||||||
</select>
|
{saving ? "Saving..." : "Save"}
|
||||||
</div>
|
</Button>
|
||||||
|
</div>
|
||||||
<div className="space-y-2">
|
</fieldset>
|
||||||
<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>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -107,44 +107,34 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSavePrefix = async (data: PrefixFormData, editId?: number) => {
|
const handleSavePrefix = (
|
||||||
if (editId) {
|
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(
|
run(
|
||||||
() =>
|
() =>
|
||||||
updatePrefix({
|
editId
|
||||||
id: editId,
|
? updatePrefix({ id: editId, ...fields })
|
||||||
text: data.text,
|
: createPrefix({ username: data.username, ...fields }),
|
||||||
color: data.color,
|
|
||||||
icon: data.icon,
|
|
||||||
effect: data.effect,
|
|
||||||
active: data.active ? 1 : 0,
|
|
||||||
}),
|
|
||||||
{
|
{
|
||||||
successMessage: "Prefix updated!",
|
successMessage: editId ? "Prefix updated!" : "Prefix created!",
|
||||||
onSuccess: () => {
|
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 = () => {
|
const openAddDialog = () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { updateRoomItem } from "@/actions/rooms";
|
import { updateRoomItem } from "@/actions/rooms";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -15,6 +16,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { useServerAction } from "@/hooks/use-server-action";
|
import { useServerAction } from "@/hooks/use-server-action";
|
||||||
|
import { useUnsavedChanges } from "@/hooks/use-unsaved-changes";
|
||||||
|
|
||||||
interface EditItem {
|
interface EditItem {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -45,9 +47,26 @@ export function EditItemDialog({ roomId, item, open, onOpenChange }: Props) {
|
|||||||
const [extraData, setExtraData] = useState("");
|
const [extraData, setExtraData] = useState("");
|
||||||
|
|
||||||
const isWall = item?.type === "i";
|
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(() => {
|
useEffect(() => {
|
||||||
if (item) {
|
if (item && open) {
|
||||||
setX(String(item.x));
|
setX(String(item.x));
|
||||||
setY(String(item.y));
|
setY(String(item.y));
|
||||||
setZ(String(item.z));
|
setZ(String(item.z));
|
||||||
@@ -55,7 +74,7 @@ export function EditItemDialog({ roomId, item, open, onOpenChange }: Props) {
|
|||||||
setWallPos(item.wallPos);
|
setWallPos(item.wallPos);
|
||||||
setExtraData(item.extraData);
|
setExtraData(item.extraData);
|
||||||
}
|
}
|
||||||
}, [item]);
|
}, [item, open]);
|
||||||
|
|
||||||
function handleSave() {
|
function handleSave() {
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
@@ -72,12 +91,15 @@ export function EditItemDialog({ roomId, item, open, onOpenChange }: Props) {
|
|||||||
};
|
};
|
||||||
run(() => updateRoomItem(payload), {
|
run(() => updateRoomItem(payload), {
|
||||||
successMessage: "Item updated and room reloaded",
|
successMessage: "Item updated and room reloaded",
|
||||||
onSuccess: () => onOpenChange(false),
|
onSuccess: () => {
|
||||||
|
markSaved();
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={requestOpenChange}>
|
||||||
<DialogContent className="sm:max-w-[480px]">
|
<DialogContent className="sm:max-w-[480px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Edit furniture</DialogTitle>
|
<DialogTitle>Edit furniture</DialogTitle>
|
||||||
@@ -86,7 +108,7 @@ export function EditItemDialog({ roomId, item, open, onOpenChange }: Props) {
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<fieldset disabled={isPending} className="space-y-4">
|
||||||
{isWall ? (
|
{isWall ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="wallPos">Wall position</Label>
|
<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)
|
Item state/data (e.g. dice value, sticky note text, color)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</fieldset>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => requestOpenChange(false)}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { useDebounce } from "@/hooks/use-debounce";
|
import { useDebounce } from "@/hooks/use-debounce";
|
||||||
|
import { confirmUnsavedNavigation } from "@/hooks/use-unsaved-changes";
|
||||||
import {
|
import {
|
||||||
type AdminNavigationOptions,
|
type AdminNavigationOptions,
|
||||||
visibleAdminNavigation,
|
visibleAdminNavigation,
|
||||||
@@ -152,6 +153,7 @@ export function SearchDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleSelect(url: string) {
|
function handleSelect(url: string) {
|
||||||
|
if (!confirmUnsavedNavigation()) return;
|
||||||
handleOpenChange(false);
|
handleOpenChange(false);
|
||||||
setQuery("");
|
setQuery("");
|
||||||
setResults([]);
|
setResults([]);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -1,50 +1,98 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useEffect } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
/** Protect browser close/reload and same-tab link navigation while editing. */
|
|
||||||
|
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) {
|
export function useUnsavedChanges(dirty: boolean, message: string) {
|
||||||
|
const guard = useRef({ dirty, message });
|
||||||
|
guard.current = { dirty, message };
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!dirty) return;
|
if (!dirty) return;
|
||||||
function beforeUnload(event: BeforeUnloadEvent) {
|
const entry = guard;
|
||||||
event.preventDefault();
|
const navigation = (window as Window & { navigation?: EventTarget })
|
||||||
event.returnValue = "";
|
.navigation;
|
||||||
|
if (guards.size === 0) {
|
||||||
|
window.addEventListener("beforeunload", beforeUnload);
|
||||||
|
document.addEventListener("click", click, true);
|
||||||
|
navigation?.addEventListener("navigate", traverse);
|
||||||
}
|
}
|
||||||
function click(event: MouseEvent) {
|
guards.add(entry);
|
||||||
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);
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("beforeunload", beforeUnload);
|
guards.delete(entry);
|
||||||
document.removeEventListener("click", click, true);
|
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 };
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user