From e18cad9c6a5d18e5e26abdf6ba654986b3b4f7e9 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Sun, 7 Jun 2026 23:26:15 +0200 Subject: [PATCH] feat(web): unsaved-changes guard (useBlocker + beforeunload) on the object form (#46) Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/i18n/en.json | 2 +- web/src/i18n/sv.json | 2 +- web/src/lib/unsaved-changes-dialog.tsx | 42 ++++++++++++ web/src/lib/use-unsaved-changes.test.tsx | 85 ++++++++++++++++++++++++ web/src/lib/use-unsaved-changes.ts | 18 +++++ web/src/objects/object-form.tsx | 8 ++- 6 files changed, 154 insertions(+), 3 deletions(-) create mode 100644 web/src/lib/unsaved-changes-dialog.tsx create mode 100644 web/src/lib/use-unsaved-changes.test.tsx create mode 100644 web/src/lib/use-unsaved-changes.ts diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json index 7ca92fd..6abe947 100644 --- a/web/src/i18n/en.json +++ b/web/src/i18n/en.json @@ -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)" }, "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" }, - "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." }, "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" }, diff --git a/web/src/i18n/sv.json b/web/src/i18n/sv.json index 6984ad8..c482800 100644 --- a/web/src/i18n/sv.json +++ b/web/src/i18n/sv.json @@ -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)" }, "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" }, - "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." }, "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" }, diff --git a/web/src/lib/unsaved-changes-dialog.tsx b/web/src/lib/unsaved-changes-dialog.tsx new file mode 100644 index 0000000..9c70271 --- /dev/null +++ b/web/src/lib/unsaved-changes-dialog.tsx @@ -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 ( + { + if (!next) blocker.reset?.(); + }} + > + + + {t("form.unsaved.title")} + {t("form.unsaved.body")} + + + blocker.reset?.()}> + {t("form.unsaved.stay")} + + blocker.proceed?.()}> + {t("form.unsaved.leave")} + + + + + ); +} diff --git a/web/src/lib/use-unsaved-changes.test.tsx b/web/src/lib/use-unsaved-changes.test.tsx new file mode 100644 index 0000000..4b469f4 --- /dev/null +++ b/web/src/lib/use-unsaved-changes.test.tsx @@ -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 ( +
+

Editor

+ go elsewhere + +
+ ); +} + +function renderGuard(active: boolean) { + const router = createMemoryRouter( + [ + { path: "/", element: }, + { path: "/other", element:

Other page

}, + ], + { initialEntries: ["/"] }, + ); + + return render(); +} + +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); +}); diff --git a/web/src/lib/use-unsaved-changes.ts b/web/src/lib/use-unsaved-changes.ts new file mode 100644 index 0000000..37db1c0 --- /dev/null +++ b/web/src/lib/use-unsaved-changes.ts @@ -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; +} diff --git a/web/src/objects/object-form.tsx b/web/src/objects/object-form.tsx index fa6a5f6..09ac7dd 100644 --- a/web/src/objects/object-form.tsx +++ b/web/src/objects/object-form.tsx @@ -6,6 +6,8 @@ import { useFieldDefinitions } from "../api/queries"; import { useConfig } from "../config/config-context"; import { FieldInput } from "./field-input"; 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 { Input } from "@/components/ui/input"; 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(() => { if (fieldErrorKey) { @@ -151,6 +155,8 @@ export function ObjectForm({ }} className="space-y-4 overflow-auto p-4" > + + {formError && (

{formError}