SheetForm dirty-state confirm + wire isDirty into all form consumers (R4.5)

SheetForm gains an isDirty prop. When true, any close attempt (Cancel
button, header X, Radix overlay click, Escape) opens a 'Discard changes?'
ConfirmDialog instead of discarding unsaved edits. Radix dismiss callbacks
(onEscapeKeyDown, onPointerDownOutside) are intercepted when dirty so the
guard applies uniformly.

All four form consumers now compute and pass isDirty:
- ServicePage: name/enabled/config differ from the persisted instance.
- Settings machine editor: field-by-field draft vs editingMachine
  (create mode is always dirty; secret write-only fields excluded).
- Message compose: subject non-empty, body differs from default, or
  attachments present.
- WidgetConfigDialog: draft !== null (only draft mode is guarded; list
  mode has nothing to discard).

Tests: 3 new SheetForm dirty-guard cases (prompt on cancel, abort discard,
clean close when not dirty) + one focused dirty-guard test per consumer.
122 tests pass; lint/build green.

Refs openspec/changes/mobile-responsive-parity/verify-report.md residual
risk #1.
This commit is contained in:
Developer
2026-06-26 15:31:30 +00:00
parent 32516f6e3b
commit 09b9c45665
9 changed files with 248 additions and 13 deletions
+10 -3
View File
@@ -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 (
<div className="flex flex-col gap-4">
@@ -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}
>
<div className="flex flex-col gap-6">
<Field label="Name" htmlFor="service-name">
+25
View File
@@ -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)}
>
<MachineEditor
key={`${machineDraft.id ?? machineDraft.mode}-${machineDraft.mode}`}
+9 -4
View File
@@ -891,7 +891,7 @@ export function UsersPage() {
<div className="flex flex-wrap gap-1">
<Tooltip>
<TooltipTrigger asChild>
<UiButton
<UiButton
variant="ghost"
size="icon"
className="mobile-touch-target"
@@ -905,7 +905,7 @@ export function UsersPage() {
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<UiButton
<UiButton
variant="ghost"
size="icon"
className="mobile-touch-target"
@@ -919,7 +919,7 @@ export function UsersPage() {
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<UiButton
<UiButton
variant="ghost"
size="icon"
className="mobile-touch-target"
@@ -933,7 +933,7 @@ export function UsersPage() {
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<UiButton
<UiButton
variant="ghost"
size="icon"
className="mobile-touch-target"
@@ -1022,6 +1022,11 @@ export function UsersPage() {
isPending={sendUserMessage.isPending}
saveDisabled={!selectedDeliverableRows.length || !subject.trim()}
saveLabel="Send message"
isDirty={
subject.trim() !== "" ||
htmlBody.trim() !== DEFAULT_HTML_BODY.trim() ||
attachments.length > 0
}
>
<div className="flex flex-col gap-4">{composeBody}</div>
</SheetForm>
@@ -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(<Settings />);
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();
});
});
@@ -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(
<TooltipProvider>
<UsersPage />
</TooltipProvider>,
);
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();
});
});