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 (
|
||||
[
|
||||
"/admin/unsaved-harness",
|
||||
"/admin/attachment-harness",
|
||||
"/admin/studio-harness",
|
||||
"/admin/jobs-harness",
|
||||
|
||||
@@ -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={[]}
|
||||
|
||||
@@ -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";
|
||||
|
||||
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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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";
|
||||
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 };
|
||||
}
|
||||
Reference in new issue
Block a user