Mobile Settings: machine editor SheetForm (Slice 7)

Below md, the machine editor Dialog renders as a SheetForm (triggered by
the same Edit/Add buttons via machineDialogOpen state). The shared
MachineEditor body (fields + SSH validate button) renders inside the
sheet; the ConfirmDialog is a sibling outside. Desktop Dialog is
byte-for-byte identical.

No navigation needed on close -- the Settings page content (tabbed cards,
machine list) is always visible behind the sheet, so there is no stranding
risk (unlike ServicePage where the sheet was the whole page).

Added saveDisabled prop to SheetForm (additive, default false) so the
machine editor can gate Save on required fields (name + host for SSH
mode), matching the desktop DialogFooter confirmDisabled semantics.

Scope note: SSHKeyManager is an inline two-panel layout (SelectionRailCard
+ SectionCard), not a dialog, and already stacks responsively via
grid-cols-1 md:grid-cols-[...]. Wrapping it in SheetForm would break its
always-visible selection rail. Left as-is.

Tests: 3 new mobile cases (SheetForm render, save payload, cancel closes)
+ desktop unchanged. 113 tests pass; lint/build green.

Refs openspec/changes/mobile-responsive-parity/ (spec R4, tasks slice 7).
This commit is contained in:
Developer
2026-06-26 13:56:27 +00:00
parent f7b63fead5
commit e805c624b2
3 changed files with 180 additions and 48 deletions
+4 -1
View File
@@ -15,6 +15,8 @@ export interface SheetFormProps {
isPending?: boolean;
/** Override the Save button label (default "Save"). */
saveLabel?: string;
/** Disable the Save button (e.g. when required fields are empty). */
saveDisabled?: boolean;
children: React.ReactNode;
/** Optional className applied to the scrolling body. */
bodyClassName?: string;
@@ -39,6 +41,7 @@ export function SheetForm({
onSave,
onCancel,
isPending = false,
saveDisabled = false,
saveLabel = "Save",
children,
bodyClassName,
@@ -75,7 +78,7 @@ export function SheetForm({
<Button variant="outline" onClick={onCancel} disabled={isPending}>
Cancel
</Button>
<Button onClick={onSave} disabled={isPending}>
<Button onClick={onSave} disabled={isPending || saveDisabled}>
{isPending ? (
<>
<Loader2 className="animate-spin" />
+95 -41
View File
@@ -17,6 +17,8 @@ import {
useSaveSSHKey,
useTestMonitoringMachineSSH,
} from "../hooks/useSettings";
import { useIsMobile } from "../hooks/useIsMobile";
import { SheetForm } from "@/components/ui/sheet-form";
import { DialogFooter } from "../components/DialogFooter";
import { HoverEditButton } from "../components/HoverEditButton";
import { SectionCard } from "../components/SectionCard";
@@ -850,6 +852,7 @@ export function Settings() {
const [editingMachine, setEditingMachine] =
useState<MonitoringMachine | null>(null);
const [selectedMachineId, setSelectedMachineId] = useState("");
const isMobile = useIsMobile();
const orderedMachines = useMemo(() => machines ?? [], [machines]);
const selectedMachine = useMemo(
() =>
@@ -1135,21 +1138,24 @@ export function Settings() {
)}
{tab === "danger" && <ResetLocalDatabaseCard />}
</TabbedCard>
<Dialog
open={machineDialogOpen}
onOpenChange={(open) => {
if (!open) closeMachineDialog();
}}
>
<DialogContent className="sm:max-w-4xl">
<DialogHeader>
<DialogTitle>
{machineDraft.id ? "Edit machine" : "Create machine"}
</DialogTitle>
<DialogDescription>
{machineDraft.mode === "local" ? "Local API host" : "SSH target"}
</DialogDescription>
</DialogHeader>
{isMobile ? (
<SheetForm
open={machineDialogOpen}
onOpenChange={(open) => {
if (!open) closeMachineDialog();
}}
title={machineDraft.id ? "Edit machine" : "Create machine"}
onSave={() => {
void saveMachineDraft(machineDraft);
}}
onCancel={closeMachineDialog}
isPending={saveMachine.isPending}
saveDisabled={
!machineDraft.name ||
(machineDraft.mode === "ssh" && !machineDraft.host.trim())
}
saveLabel={machineDraft.id ? "Save machine" : "Create machine"}
>
<MachineEditor
key={`${machineDraft.id ?? machineDraft.mode}-${machineDraft.mode}`}
title={
@@ -1170,32 +1176,80 @@ export function Settings() {
sshValidationError={sshValidationError}
sshValidationStatus={sshValidationStatus}
/>
<DialogFooter
onCancel={closeMachineDialog}
cancelLabel="Cancel"
onConfirm={() => {
void saveMachineDraft(machineDraft);
}}
confirmLabel={machineDraft.id ? "Save machine" : "Create machine"}
confirmDisabled={
!machineDraft.name ||
(machineDraft.mode === "ssh" && !machineDraft.host.trim())
}
secondaryAction={
machineDraft.id ? (
<Button
variant="destructive"
onClick={() => {
setDeleteMachineId(machineDraft.id as string);
}}
>
Delete
</Button>
) : undefined
}
/>
</DialogContent>
</Dialog>
{machineDraft.id ? (
<Button
variant="destructive"
onClick={() => setDeleteMachineId(machineDraft.id as string)}
>
Delete machine
</Button>
) : null}
</SheetForm>
) : (
<Dialog
open={machineDialogOpen}
onOpenChange={(open) => {
if (!open) closeMachineDialog();
}}
>
<DialogContent className="sm:max-w-4xl">
<DialogHeader>
<DialogTitle>
{machineDraft.id ? "Edit machine" : "Create machine"}
</DialogTitle>
<DialogDescription>
{machineDraft.mode === "local"
? "Local API host"
: "SSH target"}
</DialogDescription>
</DialogHeader>
<MachineEditor
key={`${machineDraft.id ?? machineDraft.mode}-${machineDraft.mode}`}
title={
machineDraft.id
? machineDraft.name || "Edit machine"
: "New machine"
}
hint={
machineDraft.mode === "local" ? "Local API host" : "SSH target"
}
machine={machineDraft}
sshKeys={sshKeys}
editingMachine={editingMachine}
onChange={updateMachineDraft}
onValidateSSH={validateMachineSSH}
isValidatingSSH={testMachineSSH.isPending}
sshValidationMessage={sshValidationMessage}
sshValidationError={sshValidationError}
sshValidationStatus={sshValidationStatus}
/>
<DialogFooter
onCancel={closeMachineDialog}
cancelLabel="Cancel"
onConfirm={() => {
void saveMachineDraft(machineDraft);
}}
confirmLabel={machineDraft.id ? "Save machine" : "Create machine"}
confirmDisabled={
!machineDraft.name ||
(machineDraft.mode === "ssh" && !machineDraft.host.trim())
}
secondaryAction={
machineDraft.id ? (
<Button
variant="destructive"
onClick={() => {
setDeleteMachineId(machineDraft.id as string);
}}
>
Delete
</Button>
) : undefined
}
/>
</DialogContent>
</Dialog>
)}
<ConfirmDialog
open={Boolean(deleteMachineId)}
title="Delete machine?"
+81 -6
View File
@@ -6,12 +6,10 @@ import type { MonitoringMachine } from "../../types";
const saveMachineMutate = vi.fn().mockResolvedValue({});
const deleteMachineMutate = vi.fn();
const testSSHMutate = vi
.fn()
.mockResolvedValue({
message: "SSH auth succeeded",
known_hosts_updated: true,
});
const testSSHMutate = vi.fn().mockResolvedValue({
message: "SSH auth succeeded",
known_hosts_updated: true,
});
let machines: MonitoringMachine[] = [];
@@ -113,3 +111,80 @@ describe("Settings", () => {
expect(deleteMachineMutate).toHaveBeenCalledWith("m1");
});
});
// jsdom has no window.matchMedia; default to desktop so existing tests are
// unaffected.
function setMatchMedia(matches: boolean) {
window.matchMedia = ((query: string) => ({
matches: query.includes("768") ? matches : false,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
})) as unknown as typeof window.matchMedia;
}
describe("Settings (mobile SheetForm — slice 7)", () => {
beforeEach(() => setMatchMedia(true));
it("opens the machine editor in a SheetForm below md", async () => {
machines = [localMachine()];
render(<Settings />);
// Open the editor via the detail-pane Edit button (visible text).
const detailEdit = screen
.getAllByRole("button", { name: "Edit" })
.find((button) => button.textContent === "Edit") as HTMLButtonElement;
await userEvent.click(detailEdit);
// SheetForm renders a dialog; the DialogTitle shows the editor title.
expect(screen.getByText("Edit machine")).toBeInTheDocument();
expect(screen.getByRole("dialog")).toBeInTheDocument();
// Desktop DialogDescription text is not rendered as a dialog description
// on mobile (the MachineEditor has its own hint labels, which is fine).
expect(
screen.queryByRole("heading", { name: "Create machine" }),
).not.toBeInTheDocument();
});
it("saves a machine via the SheetForm on mobile", async () => {
machines = [localMachine()];
render(<Settings />);
const detailEdit = screen
.getAllByRole("button", { name: "Edit" })
.find((button) => button.textContent === "Edit") as HTMLButtonElement;
await userEvent.click(detailEdit);
const nameInput = screen.getByLabelText("Name");
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "Renamed node");
await userEvent.click(screen.getByRole("button", { name: "Save machine" }));
expect(saveMachineMutate).toHaveBeenCalledTimes(1);
const saved = saveMachineMutate.mock.calls[0][0];
expect(saved.name).toBe("Renamed node");
expect(saved.mode).toBe("local");
});
it("cancel closes the SheetForm on mobile", async () => {
machines = [localMachine()];
render(<Settings />);
const detailEdit = screen
.getAllByRole("button", { name: "Edit" })
.find((button) => button.textContent === "Edit") as HTMLButtonElement;
await userEvent.click(detailEdit);
expect(screen.getByRole("dialog")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
// The sheet is now closed — the dialog role should no longer be present.
// (The page content itself is still rendered; only the sheet unmounts.)
expect(screen.queryByText("Edit machine")).not.toBeInTheDocument();
});
});