@@ -62,7 +107,7 @@ export function SheetForm({
variant="ghost"
size="icon-sm"
aria-label="Close"
- onClick={onCancel}
+ onClick={attemptClose}
>
@@ -75,7 +120,7 @@ export function SheetForm({
{/* Footer — fixed at bottom */}
-
+
+
setConfirmDiscardOpen(false)}
+ onConfirm={() => {
+ setConfirmDiscardOpen(false);
+ onCancel();
+ }}
+ />
);
}
diff --git a/frontend/src/pages/ServicePage.tsx b/frontend/src/pages/ServicePage.tsx
index 7f2603a..448d6ae 100644
--- a/frontend/src/pages/ServicePage.tsx
+++ b/frontend/src/pages/ServicePage.tsx
@@ -174,6 +174,12 @@ export function ServicePage() {
/>
);
+ // Dirty when any editable field diverges from the persisted instance (mobile SheetForm R4.5 guard).
+ const isDirty =
+ name !== instance.name ||
+ enabled !== instance.enabled ||
+ JSON.stringify(draftConfig) !== JSON.stringify(instance.config);
+
if (isMobile) {
return (
@@ -183,10 +189,11 @@ export function ServicePage() {
title={name || instance.name}
onSave={save}
onCancel={() => {
- setSheetOpen(false);
- navigate("/services");
- }}
+ setSheetOpen(false);
+ navigate("/services");
+ }}
isPending={saveService.isPending}
+ isDirty={isDirty}
>
diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx
index 71811fe..4a99293 100644
--- a/frontend/src/pages/Settings.tsx
+++ b/frontend/src/pages/Settings.tsx
@@ -128,6 +128,30 @@ function emptyMachine(
};
}
+/**
+ * Dirty check for the machine editor SheetForm guard (spec R4.5).
+ * Pragmatic field-by-field comparison of the user-editable fields. In create
+ * mode (editingMachine is null) the form is always dirty.
+ */
+function isMachineDraftDirty(
+ draft: MonitoringMachineInput,
+ editingMachine: MonitoringMachine | null,
+): boolean {
+ if (!editingMachine) return true;
+ return (
+ draft.name !== editingMachine.name ||
+ draft.host !== editingMachine.host ||
+ draft.mode !== editingMachine.mode ||
+ draft.port !== editingMachine.port ||
+ draft.username !== editingMachine.username ||
+ draft.ssh_key_id !== editingMachine.ssh_key_id ||
+ draft.enabled !== editingMachine.enabled ||
+ draft.notes !== editingMachine.notes ||
+ JSON.stringify([...draft.services].sort()) !==
+ JSON.stringify([...editingMachine.services].sort())
+ );
+}
+
function MachineEditor({
title,
hint,
@@ -1159,6 +1183,7 @@ export function Settings() {
(machineDraft.mode === "ssh" && !machineDraft.host.trim())
}
saveLabel={machineDraft.id ? "Save machine" : "Create machine"}
+ isDirty={isMachineDraftDirty(machineDraft, editingMachine)}
>
-
-
-
- 0
+ }
>
{composeBody}
diff --git a/frontend/src/pages/__tests__/Settings.test.tsx b/frontend/src/pages/__tests__/Settings.test.tsx
index 7530a7a..174a3ab 100644
--- a/frontend/src/pages/__tests__/Settings.test.tsx
+++ b/frontend/src/pages/__tests__/Settings.test.tsx
@@ -187,4 +187,27 @@ describe("Settings (mobile SheetForm — slice 7)", () => {
// (The page content itself is still rendered; only the sheet unmounts.)
expect(screen.queryByText("Edit machine")).not.toBeInTheDocument();
});
+
+ it("prompts before discarding unsaved machine edits (R4.5)", async () => {
+ machines = [localMachine()];
+ render();
+
+ const detailEdit = screen
+ .getAllByRole("button", { name: "Edit" })
+ .find((button) => button.textContent === "Edit") as HTMLButtonElement;
+ await userEvent.click(detailEdit);
+
+ // Edit the name to make the form dirty.
+ const nameInput = screen.getByLabelText("Name");
+ await userEvent.clear(nameInput);
+ await userEvent.type(nameInput, "Dirty name");
+
+ // Cancel should NOT immediately close — the discard confirm appears.
+ await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
+ expect(
+ screen.getByRole("heading", { name: "Discard changes?" }),
+ ).toBeInTheDocument();
+ // The editor is still open.
+ expect(screen.getByText("Edit machine")).toBeInTheDocument();
+ });
});
diff --git a/frontend/src/pages/__tests__/UsersPage.test.tsx b/frontend/src/pages/__tests__/UsersPage.test.tsx
index d465976..7bc8677 100644
--- a/frontend/src/pages/__tests__/UsersPage.test.tsx
+++ b/frontend/src/pages/__tests__/UsersPage.test.tsx
@@ -373,4 +373,35 @@ describe("UsersPage (mobile card layout — slice 5)", () => {
).toBeInTheDocument();
expect(screen.getByLabelText("Subject")).toBeInTheDocument();
});
+
+ it("prompts before discarding unsaved compose edits (R4.5)", async () => {
+ users = [
+ userFixture({
+ jellyfin_id: "u1",
+ display_name: "Alice",
+ email: "alice@example.com",
+ }),
+ ];
+ render(
+
+
+ ,
+ );
+
+ await userEvent.click(
+ screen.getByRole("checkbox", { name: /Select Alice/i }),
+ );
+ await userEvent.click(
+ screen.getByRole("button", { name: "Message selected" }),
+ );
+
+ // Type a subject to make the compose form dirty.
+ await userEvent.type(screen.getByLabelText("Subject"), "Urgent update");
+
+ // Cancel should NOT immediately close — the discard confirm appears.
+ await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
+ expect(
+ screen.getByRole("heading", { name: "Discard changes?" }),
+ ).toBeInTheDocument();
+ });
});