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:
@@ -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" },
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user