diff --git a/frontend/src/pages/ServicePage.tsx b/frontend/src/pages/ServicePage.tsx index 304f403..453a534 100644 --- a/frontend/src/pages/ServicePage.tsx +++ b/frontend/src/pages/ServicePage.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from "react"; -import { useParams } from "react-router-dom"; +import { useNavigate, useParams } from "react-router-dom"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -12,6 +12,7 @@ import { useServiceInstances, useServiceTypes, } from "../hooks/useServices"; +import { useIsMobile } from "../hooks/useIsMobile"; import type { ServiceInstance, ServiceInstanceInput, @@ -19,6 +20,7 @@ import type { } from "../types"; import { SectionCard } from "../components/SectionCard"; import { ConfirmDialog } from "../components/ConfirmDialog"; +import { SheetForm } from "@/components/ui/sheet-form"; import { getServiceBinding } from "../integrations/registry"; function Field({ @@ -63,11 +65,17 @@ export function ServicePage() { [types, serviceType], ); + const navigate = useNavigate(); const [name, setName] = useState(""); const [enabled, setEnabled] = useState(true); const [draftConfig, setDraftConfig] = useState>({}); const [deleteOpen, setDeleteOpen] = useState(false); const [hydrated, setHydrated] = useState(false); + const isMobile = useIsMobile(); + // The mobile SheetForm opens by default when the page loads: this page is + // reached via /services/:serviceType/:serviceId, always editing an existing + // instance, so there is no separate "open edit" trigger on mobile. + const [sheetOpen, setSheetOpen] = useState(true); // Hydrate local form state once the instance loads. if (instance && !hydrated) { @@ -106,6 +114,106 @@ export function ServicePage() { async function save() { await saveService.mutateAsync(buildInput()); + // R4.5: close the sheet on successful save and return to the services list + // (on mobile the sheet IS the page, so closing it would strand the user). + if (isMobile) { + setSheetOpen(false); + navigate("/services"); + } + } + + const configFields = ( + + ); + + const widgetsCard = + binding.widgets.length > 0 ? ( + +
+ {binding.widgets.map((w) => ( +
+
+
{w.name}
+
+ {w.description} +
+
+ {w.kind} +
+ ))} +

+ Add these to the dashboard from the dashboard's edit dialog. +

+
+
+ ) : null; + + const confirmDelete = ( + setDeleteOpen(false)} + onConfirm={() => { + deleteService.mutate(instance.id); + setDeleteOpen(false); + }} + /> + ); + + if (isMobile) { + return ( +
+ { + setSheetOpen(false); + navigate("/services"); + }} + isPending={saveService.isPending} + > +
+ + setName(e.target.value)} + /> + +
+ + +
+ {configFields} + + {widgetsCard} +
+
+ {confirmDelete} +
+ ); } return ( @@ -146,65 +254,27 @@ export function ServicePage() { - + {configFields} - {binding.widgets.length > 0 ? ( - -
- {binding.widgets.map((w) => ( -
-
-
{w.name}
-
- {w.description} -
-
- {w.kind} -
- ))} -

- Add these to the dashboard from the dashboard's edit dialog. -

-
-
- ) : null} + {widgetsCard} - setDeleteOpen(false)} - onConfirm={() => { - deleteService.mutate(instance.id); - setDeleteOpen(false); - }} - /> + {confirmDelete} ); } -function ServiceConnectionCard({ +function ServiceConnectionFields({ instance, typeInfo, draftConfig, onConfigChange, + isMobile, }: { instance: ServiceInstance; typeInfo: ServiceTypeInfo | undefined; draftConfig: Record; onConfigChange: (config: Record) => void; + isMobile: boolean; }) { const saveService = useSaveServiceInstance(); // Empty-on-edit: local state starts blank; a blank field means "keep existing". @@ -232,96 +302,105 @@ function ServiceConnectionCard({ { type: typeof value === "number" ? "integer" : "string" }, ]); + function handleUpdateConnection() { + const onlyChanged = Object.fromEntries( + Object.entries(draftSecrets).filter(([, v]) => v !== ""), + ); + saveService.mutate({ + id: instance.id, + service_type: instance.service_type, + name: instance.name, + config: draftConfig, + secrets: onlyChanged, + enabled: instance.enabled, + }); + setDraftSecrets({}); + } + + const fields = ( +
+ {configEntries.length === 0 ? ( +

No connection config.

+ ) : ( +
+ {configEntries.map(([key, schema]) => { + const isNumber = + schema.type === "integer" || schema.type === "number"; + return ( + + + onConfigChange({ + ...draftConfig, + [key]: isNumber + ? e.target.value === "" + ? undefined + : Number(e.target.value) + : e.target.value, + }) + } + /> + + ); + })} +
+ )} + + {Object.keys(instance.secrets_set).length === 0 ? ( +

No secret fields.

+ ) : ( +
+ {Object.entries(instance.secrets_set).map(([key, isSet]) => ( +
+ + + setDraftSecrets({ + ...draftSecrets, + [key]: e.target.value, + }) + } + /> + + {isSet ? set : null} +
+ ))} +
+ )} + + +
+ ); + + // On mobile the fields render inside the SheetForm body without a card + // wrapper (the SheetForm already provides the container). On desktop they + // keep their original SectionCard framing. + if (isMobile) { + return
{fields}
; + } + return ( -
- {configEntries.length === 0 ? ( -

No connection config.

- ) : ( -
- {configEntries.map(([key, schema]) => { - const isNumber = - schema.type === "integer" || schema.type === "number"; - return ( - - - onConfigChange({ - ...draftConfig, - [key]: isNumber - ? e.target.value === "" - ? undefined - : Number(e.target.value) - : e.target.value, - }) - } - /> - - ); - })} -
- )} - - {Object.keys(instance.secrets_set).length === 0 ? ( -

No secret fields.

- ) : ( -
- {Object.entries(instance.secrets_set).map(([key, isSet]) => ( -
- - - setDraftSecrets({ - ...draftSecrets, - [key]: e.target.value, - }) - } - /> - - {isSet ? set : null} -
- ))} -
- )} - - -
+ {fields}
); } diff --git a/frontend/src/pages/__tests__/ServicePage.test.tsx b/frontend/src/pages/__tests__/ServicePage.test.tsx new file mode 100644 index 0000000..b142904 --- /dev/null +++ b/frontend/src/pages/__tests__/ServicePage.test.tsx @@ -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(); + // 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(); + // 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(); + // 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(); + 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(); + const urlInput = screen.getByLabelText("base_url"); + expect(urlInput).toHaveValue("https://grafana.example.com"); + }); +});