feat(web): unsaved-changes guard (useBlocker + beforeunload) on the object form (#46)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 23:26:15 +02:00
parent 537b847acb
commit e18cad9c6a
6 changed files with 154 additions and 3 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
"objects": { "title": "Objects", "empty": "No objects yet", "loadError": "Could not load objects", "notFound": "Object not found", "prev": "Previous", "next": "Next", "of": "of", "new": "New object", "filter": "Filter objects…", "pageSize": "Per page", "columns": { "number": "Object №", "name": "Name", "visibility": "Visibility", "location": "Location", "count": "#", "updated": "Updated" }, "unknownRef": "(unknown)" }, "objects": { "title": "Objects", "empty": "No objects yet", "loadError": "Could not load objects", "notFound": "Object not found", "prev": "Previous", "next": "Next", "of": "of", "new": "New object", "filter": "Filter objects…", "pageSize": "Per page", "columns": { "number": "Object №", "name": "Name", "visibility": "Visibility", "location": "Location", "count": "#", "updated": "Updated" }, "unknownRef": "(unknown)" },
"fieldsLabels": { "objectNumber": "Object number", "objectName": "Name", "count": "Number of objects", "briefDescription": "Brief description", "currentLocation": "Current location", "currentOwner": "Current owner", "recorder": "Recorder", "recordingDate": "Recording date", "visibility": "Visibility" }, "fieldsLabels": { "objectNumber": "Object number", "objectName": "Name", "count": "Number of objects", "briefDescription": "Brief description", "currentLocation": "Current location", "currentOwner": "Current owner", "recorder": "Recorder", "recordingDate": "Recording date", "visibility": "Visibility" },
"visibility": { "draft": "Draft", "internal": "Internal", "public": "Public" }, "visibility": { "draft": "Draft", "internal": "Internal", "public": "Public" },
"form": { "selectPlaceholder": "— select —", "create": "Create object", "save": "Save", "cancel": "Cancel", "visibility": "Visibility", "draft": "Draft", "internal": "Internal", "required": "This field is required", "rejected": "The server rejected the changes — check required and referenced fields", "fieldRejected": "The field \"{{field}}\" was rejected — check its value", "flexibleHeading": "Catalogue fields", "saving": "Saving…", "createAnother": "Save & create another", "minCount": "Must be at least 1", "fieldError": { "type_mismatch": "Wrong type for this field", "unresolved": "Referenced value not found", "unknown": "Unknown field" } }, "form": { "selectPlaceholder": "— select —", "create": "Create object", "save": "Save", "cancel": "Cancel", "visibility": "Visibility", "draft": "Draft", "internal": "Internal", "required": "This field is required", "rejected": "The server rejected the changes — check required and referenced fields", "fieldRejected": "The field \"{{field}}\" was rejected — check its value", "flexibleHeading": "Catalogue fields", "saving": "Saving…", "createAnother": "Save & create another", "minCount": "Must be at least 1", "fieldError": { "type_mismatch": "Wrong type for this field", "unresolved": "Referenced value not found", "unknown": "Unknown field" }, "unsaved": { "title": "Discard unsaved changes?", "body": "You have unsaved changes that will be lost.", "stay": "Keep editing", "leave": "Discard" } },
"actions": { "edit": "Edit", "delete": "Delete", "rename": "Rename", "save": "Save", "closeDetail": "Close detail", "confirmDelete": "Delete this object? This cannot be undone.", "confirmDeleteTerm": "Delete this term? This cannot be undone.", "confirmDeleteAuthority": "Delete this authority? This cannot be undone.", "confirmDeleteField": "Delete this field definition? This cannot be undone.", "confirmDeleteVocabulary": "Delete this vocabulary? This cannot be undone.", "inUse": "Can't delete — used by {{count}} object(s). Clear those fields first." }, "actions": { "edit": "Edit", "delete": "Delete", "rename": "Rename", "save": "Save", "closeDetail": "Close detail", "confirmDelete": "Delete this object? This cannot be undone.", "confirmDeleteTerm": "Delete this term? This cannot be undone.", "confirmDeleteAuthority": "Delete this authority? This cannot be undone.", "confirmDeleteField": "Delete this field definition? This cannot be undone.", "confirmDeleteVocabulary": "Delete this vocabulary? This cannot be undone.", "inUse": "Can't delete — used by {{count}} object(s). Clear those fields first." },
"labels": { "label": "Label", "externalUri": "External URI (optional)", "otherLanguages": "This entry also has labels in other languages, which are kept." }, "labels": { "label": "Label", "externalUri": "External URI (optional)", "otherLanguages": "This entry also has labels in other languages, which are kept." },
"theme": { "light": "Light", "dark": "Dark", "system": "System" }, "theme": { "light": "Light", "dark": "Dark", "system": "System" },
+1 -1
View File
@@ -5,7 +5,7 @@
"objects": { "title": "Föremål", "empty": "Inga föremål ännu", "loadError": "Kunde inte ladda föremål", "notFound": "Föremålet hittades inte", "prev": "Föregående", "next": "Nästa", "of": "av", "new": "Nytt föremål", "filter": "Filtrera föremål…", "pageSize": "Per sida", "columns": { "number": "Föremålsnr", "name": "Namn", "visibility": "Synlighet", "location": "Plats", "count": "Antal", "updated": "Uppdaterad" }, "unknownRef": "(okänd)" }, "objects": { "title": "Föremål", "empty": "Inga föremål ännu", "loadError": "Kunde inte ladda föremål", "notFound": "Föremålet hittades inte", "prev": "Föregående", "next": "Nästa", "of": "av", "new": "Nytt föremål", "filter": "Filtrera föremål…", "pageSize": "Per sida", "columns": { "number": "Föremålsnr", "name": "Namn", "visibility": "Synlighet", "location": "Plats", "count": "Antal", "updated": "Uppdaterad" }, "unknownRef": "(okänd)" },
"fieldsLabels": { "objectNumber": "Föremålsnummer", "objectName": "Namn", "count": "Antal föremål", "briefDescription": "Kort beskrivning", "currentLocation": "Nuvarande plats", "currentOwner": "Nuvarande ägare", "recorder": "Registrerad av", "recordingDate": "Registreringsdatum", "visibility": "Synlighet" }, "fieldsLabels": { "objectNumber": "Föremålsnummer", "objectName": "Namn", "count": "Antal föremål", "briefDescription": "Kort beskrivning", "currentLocation": "Nuvarande plats", "currentOwner": "Nuvarande ägare", "recorder": "Registrerad av", "recordingDate": "Registreringsdatum", "visibility": "Synlighet" },
"visibility": { "draft": "Utkast", "internal": "Intern", "public": "Publik" }, "visibility": { "draft": "Utkast", "internal": "Intern", "public": "Publik" },
"form": { "selectPlaceholder": "— välj —", "create": "Skapa föremål", "save": "Spara", "cancel": "Avbryt", "visibility": "Synlighet", "draft": "Utkast", "internal": "Intern", "required": "Fältet är obligatoriskt", "rejected": "Servern avvisade ändringarna — kontrollera obligatoriska och refererade fält", "fieldRejected": "Fältet \"{{field}}\" avvisades — kontrollera värdet", "flexibleHeading": "Katalogfält", "saving": "Sparar…", "createAnother": "Spara & skapa ny", "minCount": "Måste vara minst 1", "fieldError": { "type_mismatch": "Fel typ för detta fält", "unresolved": "Refererat värde hittades inte", "unknown": "Okänt fält" } }, "form": { "selectPlaceholder": "— välj —", "create": "Skapa föremål", "save": "Spara", "cancel": "Avbryt", "visibility": "Synlighet", "draft": "Utkast", "internal": "Intern", "required": "Fältet är obligatoriskt", "rejected": "Servern avvisade ändringarna — kontrollera obligatoriska och refererade fält", "fieldRejected": "Fältet \"{{field}}\" avvisades — kontrollera värdet", "flexibleHeading": "Katalogfält", "saving": "Sparar…", "createAnother": "Spara & skapa ny", "minCount": "Måste vara minst 1", "fieldError": { "type_mismatch": "Fel typ för detta fält", "unresolved": "Refererat värde hittades inte", "unknown": "Okänt fält" }, "unsaved": { "title": "Kasta osparade ändringar?", "body": "Du har osparade ändringar som går förlorade.", "stay": "Fortsätt redigera", "leave": "Kasta" } },
"actions": { "edit": "Redigera", "delete": "Ta bort", "rename": "Byt namn", "save": "Spara", "closeDetail": "Stäng detalj", "confirmDelete": "Ta bort detta föremål? Detta kan inte ångras.", "confirmDeleteTerm": "Ta bort denna term? Detta kan inte ångras.", "confirmDeleteAuthority": "Ta bort denna auktoritet? Detta kan inte ångras.", "confirmDeleteField": "Ta bort denna fältdefinition? Detta kan inte ångras.", "confirmDeleteVocabulary": "Ta bort denna vokabulär? Detta kan inte ångras.", "inUse": "Kan inte ta bort — används av {{count}} föremål. Rensa de fälten först." }, "actions": { "edit": "Redigera", "delete": "Ta bort", "rename": "Byt namn", "save": "Spara", "closeDetail": "Stäng detalj", "confirmDelete": "Ta bort detta föremål? Detta kan inte ångras.", "confirmDeleteTerm": "Ta bort denna term? Detta kan inte ångras.", "confirmDeleteAuthority": "Ta bort denna auktoritet? Detta kan inte ångras.", "confirmDeleteField": "Ta bort denna fältdefinition? Detta kan inte ångras.", "confirmDeleteVocabulary": "Ta bort denna vokabulär? Detta kan inte ångras.", "inUse": "Kan inte ta bort — används av {{count}} föremål. Rensa de fälten först." },
"labels": { "label": "Etikett", "externalUri": "Extern URI (valfritt)", "otherLanguages": "Denna post har även etiketter på andra språk, som behålls." }, "labels": { "label": "Etikett", "externalUri": "Extern URI (valfritt)", "otherLanguages": "Denna post har även etiketter på andra språk, som behålls." },
"theme": { "light": "Ljust", "dark": "Mörkt", "system": "System" }, "theme": { "light": "Ljust", "dark": "Mörkt", "system": "System" },
+42
View File
@@ -0,0 +1,42 @@
import { useTranslation } from "react-i18next";
import type { Blocker } from "react-router-dom";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
export function UnsavedChangesDialog({ blocker }: { blocker: Blocker }) {
const { t } = useTranslation();
const open = blocker.state === "blocked";
return (
<AlertDialog
open={open}
onOpenChange={(next) => {
if (!next) blocker.reset?.();
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("form.unsaved.title")}</AlertDialogTitle>
<AlertDialogDescription>{t("form.unsaved.body")}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => blocker.reset?.()}>
{t("form.unsaved.stay")}
</AlertDialogCancel>
<AlertDialogAction onClick={() => blocker.proceed?.()}>
{t("form.unsaved.leave")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
+85
View File
@@ -0,0 +1,85 @@
import { afterEach, expect, test, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createMemoryRouter, Link, RouterProvider } from "react-router-dom";
import { UnsavedChangesDialog } from "./unsaved-changes-dialog";
import { useUnsavedChanges } from "./use-unsaved-changes";
import "../i18n";
function Editor({ active }: { active: boolean }) {
const blocker = useUnsavedChanges(active);
return (
<div>
<h1>Editor</h1>
<Link to="/other">go elsewhere</Link>
<UnsavedChangesDialog blocker={blocker} />
</div>
);
}
function renderGuard(active: boolean) {
const router = createMemoryRouter(
[
{ path: "/", element: <Editor active={active} /> },
{ path: "/other", element: <h1>Other page</h1> },
],
{ initialEntries: ["/"] },
);
return render(<RouterProvider router={router} />);
}
afterEach(() => {
vi.restoreAllMocks();
});
test("dirty nav shows the dialog and Keep editing stays", async () => {
renderGuard(true);
await userEvent.click(screen.getByRole("link", { name: /go elsewhere/i }));
expect(await screen.findByText("Discard unsaved changes?")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Keep editing" }));
expect(screen.getByRole("heading", { name: "Editor" })).toBeInTheDocument();
expect(screen.queryByText("Other page")).not.toBeInTheDocument();
});
test("dirty nav with Discard proceeds to the target route", async () => {
renderGuard(true);
await userEvent.click(screen.getByRole("link", { name: /go elsewhere/i }));
await userEvent.click(await screen.findByRole("button", { name: "Discard" }));
expect(await screen.findByText("Other page")).toBeInTheDocument();
});
test("clean form navigates without the dialog", async () => {
renderGuard(false);
await userEvent.click(screen.getByRole("link", { name: /go elsewhere/i }));
expect(await screen.findByText("Other page")).toBeInTheDocument();
expect(screen.queryByText("Discard unsaved changes?")).not.toBeInTheDocument();
});
test("registers a beforeunload listener only when active", () => {
const addSpy = vi.spyOn(window, "addEventListener");
const removeSpy = vi.spyOn(window, "removeEventListener");
const { unmount } = renderGuard(true);
expect(addSpy.mock.calls.some(([type]) => type === "beforeunload")).toBe(true);
unmount();
expect(removeSpy.mock.calls.some(([type]) => type === "beforeunload")).toBe(true);
});
test("does not register beforeunload when inactive", () => {
const addSpy = vi.spyOn(window, "addEventListener");
renderGuard(false);
expect(addSpy.mock.calls.some(([type]) => type === "beforeunload")).toBe(false);
});
+18
View File
@@ -0,0 +1,18 @@
import { useEffect } from "react";
import { useBlocker } from "react-router-dom";
export function useUnsavedChanges(active: boolean) {
const blocker = useBlocker(active);
useEffect(() => {
if (!active) return;
const handler = (e: BeforeUnloadEvent) => {
e.preventDefault();
e.returnValue = "";
};
window.addEventListener("beforeunload", handler);
return () => window.removeEventListener("beforeunload", handler);
}, [active]);
return blocker;
}
+7 -1
View File
@@ -6,6 +6,8 @@ import { useFieldDefinitions } from "../api/queries";
import { useConfig } from "../config/config-context"; import { useConfig } from "../config/config-context";
import { FieldInput } from "./field-input"; import { FieldInput } from "./field-input";
import { pruneFields } from "./prune-fields"; import { pruneFields } from "./prune-fields";
import { UnsavedChangesDialog } from "../lib/unsaved-changes-dialog";
import { useUnsavedChanges } from "../lib/use-unsaved-changes";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -78,7 +80,9 @@ export function ObjectForm({
}, },
}); });
const { register, handleSubmit, formState: { errors, isSubmitting } } = form; const { register, handleSubmit, formState: { errors, isSubmitting, isDirty } } = form;
const blocker = useUnsavedChanges(isDirty && !isSubmitting);
useEffect(() => { useEffect(() => {
if (fieldErrorKey) { if (fieldErrorKey) {
@@ -151,6 +155,8 @@ export function ObjectForm({
}} }}
className="space-y-4 overflow-auto p-4" className="space-y-4 overflow-auto p-4"
> >
<UnsavedChangesDialog blocker={blocker} />
{formError && ( {formError && (
<p role="alert" className="text-sm text-destructive"> <p role="alert" className="text-sm text-destructive">
{formError} {formError}