Mobile ServicePage: SheetForm edit + close-on-save navigation (Slice 6)

Below md, ServicePage renders the edit form inside a SheetForm (open on
mount -- this page always edits an existing instance reached via
/services/:type/:id). The sheet body holds Name + Enabled + Connection
fields (no SectionCard wrapper, the sheet is the container) + Delete +
Widgets. At md+ the existing full-page layout renders token-identical.

Refactor: extracted the desktop inline JSX into configFields/widgetsCard/
confirmDelete consts and renamed ServiceConnectionCard ->
ServiceConnectionFields (isMobile prop drops the SectionCard wrapper on
mobile). Desktop output unchanged.

Fixes from Slice 6 review:
- R4.5: save() now closes the sheet on successful save (was staying open).
- Closing the sheet (save or cancel) navigates back to /services -- on
  mobile the sheet IS the page, so closing it would strand the user on a
  blank div. Added useNavigate.
- Strengthened the mobile save test to assert the full payload
  (name, id, enabled, secrets:{}, config), not just name+id.

Out of scope (flagged for verify pass): R4.5 dirty-state outside-click
confirm is a broader SheetForm concern not yet implemented.

Tests: 5 new (2 desktop non-regression + no-dialog, 3 mobile sheet render +
save payload + editable config). useNavigate added to the router mock.
110 tests pass; lint/build green.

Refs openspec/changes/mobile-responsive-parity/ (spec R4, tasks slice 6).
This commit is contained in:
Developer
2026-06-26 13:44:50 +00:00
parent 2eb649eceb
commit f7b63fead5
2 changed files with 358 additions and 130 deletions
@@ -0,0 +1,149 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ServicePage } from "../ServicePage";
import type {
ServiceInstance,
ServiceInstanceInput,
ServiceTypeInfo,
} from "../../types";
// --- fixtures ---
const instance: ServiceInstance = {
id: "svc-1",
service_type: "grafana",
name: "Production Grafana",
config: { base_url: "https://grafana.example.com", timeout_seconds: 5 },
secrets_set: { api_key: true },
enabled: true,
created_at: 1_700_000_000,
updated_at: 1_700_000_000,
};
const typeInfo: ServiceTypeInfo = {
service_type: "grafana",
name: "Grafana",
description: "Dashboards, metrics, and logs.",
config_schema: {
type: "object",
properties: {
base_url: { type: "string", description: "Absolute URL." },
timeout_seconds: { type: "integer" },
},
},
secret_fields: [{ key: "api_key", label: "API key", required: false }],
widget_kinds: [],
};
// --- mocks ---
const mutateAsync = vi.fn();
const mutate = vi.fn();
const deleteMutate = vi.fn();
vi.mock("../../hooks/useServices", () => ({
useServiceInstances: () => ({ data: [instance] }),
useServiceTypes: () => ({ data: [typeInfo] }),
useSaveServiceInstance: () => ({
mutateAsync,
mutate,
isPending: false,
}),
useDeleteServiceInstance: () => ({ mutate: deleteMutate, isPending: false }),
}));
vi.mock("react-router-dom", () => ({
useParams: () => ({
serviceType: "grafana",
serviceId: "svc-1",
}),
useNavigate: () => vi.fn(),
}));
// jsdom has no window.matchMedia; stub it. Default to desktop (matches: false).
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;
}
beforeEach(() => {
setMatchMedia(false);
mutateAsync.mockReset();
mutate.mockReset();
deleteMutate.mockReset();
});
describe("ServicePage (desktop)", () => {
it("renders the full-page layout with the service name and connection card", () => {
render(<ServicePage />);
// Page heading (desktop only — mobile uses SheetForm title)
expect(
screen.getByRole("heading", { name: "Production Grafana" }),
).toBeInTheDocument();
// Connection section card title
expect(screen.getByText("Connection")).toBeInTheDocument();
// General Save button
expect(screen.getByRole("button", { name: "Save" })).toBeInTheDocument();
});
it("does not render the SheetForm at desktop width", () => {
render(<ServicePage />);
// SheetForm renders a dialog with role="dialog" only when open; on
// desktop the page layout is used, so no dialog should be present.
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
});
describe("ServicePage (mobile SheetForm — slice 6)", () => {
beforeEach(() => setMatchMedia(true));
it("renders the SheetForm with the service name as title below md", () => {
render(<ServicePage />);
// SheetForm title is rendered inside a SheetTitle (role="heading").
expect(
screen.getByRole("heading", { name: "Production Grafana" }),
).toBeInTheDocument();
// The dialog (Sheet content) should be present on mobile.
expect(screen.getByRole("dialog")).toBeInTheDocument();
// Desktop page header description is NOT rendered inside the SheetForm.
expect(
screen.queryByText("Dashboards, metrics, and logs."),
).not.toBeInTheDocument();
});
it("edits the name field and Save calls the save mutation", async () => {
render(<ServicePage />);
const nameInput = screen.getByLabelText("Name");
expect(nameInput).toHaveValue("Production Grafana");
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "Renamed Grafana");
const saveButton = screen.getByRole("button", { name: "Save" });
await userEvent.click(saveButton);
expect(mutateAsync).toHaveBeenCalledTimes(1);
const input = mutateAsync.mock.calls[0][0] as ServiceInstanceInput;
expect(input.name).toBe("Renamed Grafana");
expect(input.id).toBe("svc-1");
// Lock the full save payload (config draft, enabled, secrets sentinel).
expect(input.enabled).toBe(true);
expect(input.secrets).toEqual({});
expect(input.config).toMatchObject({ base_url: "https://grafana.example.com" });
});
it("renders the connection config fields as editable inside the SheetForm", () => {
render(<ServicePage />);
const urlInput = screen.getByLabelText("base_url");
expect(urlInput).toHaveValue("https://grafana.example.com");
});
});