fix: reset service editor on switch + add service-page settings shortcut
Settings.tsx: ServiceConfigEditor derived editable state (name, config,
secrets) from the instance prop via useState, but the parent rendered it
without a key. Switching services in the rail reused the same component, so
name/config stayed pinned to the previously selected service while
instance.id/service_type (read live from props) pointed at the new one —
saving then wrote the stale values onto the wrong row (e.g. saving qBittorrent
renamed it "Jellyfin" with Jellyfin's URL). Add key={selectedService.id} so
the editor remounts and resets on switch.
ServicePage: add a Settings shortcut in the header that deep-links to
/settings?tab=services&service=<id>. Settings now reads tab + service query
params (useSearchParams) to open the Services tab with that service
pre-selected, via a new initialServiceId prop on ServicesAdminCard.
Tests: new Settings.services.test.tsx regression test (fails without the key,
passes with it); wrap existing Settings tests in MemoryRouter since Settings
now uses useSearchParams. 166/166 frontend tests pass; typecheck + ESLint clean.
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
dir: frontend/src/pages
|
||||
|
||||
## role
|
||||
Top-level page components that render the main UI views for dashboards, services, and settings in the application.
|
||||
Top-level page components that render the application's primary UI views including dashboards, service management, and settings.
|
||||
## parent
|
||||
index: frontend/src/.pi-map.index.md
|
||||
map: frontend/src/.pi-map.md
|
||||
|
||||
@@ -4,18 +4,18 @@ dir: frontend/src/pages
|
||||
index: frontend/src/pages/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Top-level page components that render the main UI views for dashboards, services, and settings in the application.
|
||||
Top-level page components that render the application's primary UI views including dashboards, service management, and settings.
|
||||
## files
|
||||
- Dashboard.tsx | This file renders the main dashboard page, displaying widget instances grouped by category and user-created shortcuts with full CRUD dialog management. | exp: func:Dashboard(), call:useNavigate, call:useDashboardShortcuts, call:useSaveDashboardShortcut, call:useDeleteDashboardShortcut, call:useState, call:emptyShortcut, call:useWidgetInstances, call:useWidgetReferences, call:useServiceInstances, call:useIsMobile, call:useMemo, call:widgetReferences .filter((r) => r.widget.enabled) .map, call:[...widgetInstances, ...refs] .filter((w) => w.enabled) .sort, call:widgetReferences.map, call:useDetachWidgetReference, call:groupWidgetsBySection, call:setShortcutDraft, call:setShortcutDialogOpen, call:saveShortcut.mutateAsync, call:navigate, call:setWidgetDialogOpen, call:shortcuts.map, call:shortcutHref, call:window.open, call:openEditShortcut, call:setDeleteShortcutId, call:setEditWidgetId, call:widgetReferences.find, call:detachRef.mutate, call:visibleWidgets.map, call:referencedWidgetIds.has, call:Boolean, call:deleteShortcut.mutate | dep: react, react-router-dom, lucide-react, @/components/ui/alert, @/components/ui/badge, @/components/ui/button, @/components/ui/card, @/components/ui/dialog, @/components/ui/input, @/components/ui/label, @/components/ui/select, @/components/ui/switch, ../hooks/useDashboard, ../hooks/useWidgets, ../hooks/useServices, ../hooks/useIsMobile, ../types, ../components/SectionCard, ../components/ConfirmDialog, ../components/DialogFooter, ../components/WidgetInstance, ../components/WidgetConfigDialog, @/components/ui/*
|
||||
- NamedDashboardPage.tsx | Renders a named dashboard page that displays pinned service links and configurable widgets based on a slug-based lookup. | exp: func:NamedDashboardPage(), call:useParams, call:useDashboardBySlug, call:useWidgetReferences, call:useState, call:useMemo, call:parseItems, call:widgetRefs .filter((r) => r.widget.enabled) .map((r) => r.widget) .sort, call:setConfigOpen, call:visibleWidgets.map, call:setEditWidgetId, call:items.map | dep: react, react-router-dom, lucide-react, @/components/ui/alert, @/components/ui/button, @/components/ui/skeleton, ../hooks/useDashboards, ../hooks/useWidgets, ../components/PinnedServiceLink, ../components/WidgetInstance, ../components/WidgetConfigDialog, useDashboards hook, useWidgets hook, PinnedServiceLink, WidgetInstanceCard, WidgetConfigDialog
|
||||
- ServicePage.tsx | Displays a detailed service instance page with tabbed content, instance switching, and widget information based on URL parameters. | exp: func:ServicePage(), call:useParams, call:useServiceInstances, call:useNavigate, call:useMemo, call:services.find, call:getServiceBinding, call:serviceContentTabs, call:services.filter, call:binding.widgets.map, call:siblings.map, call:navigate, call:contentTabs.map, call:allTabs.map | dep: react, react-router-dom, @/components/ui/alert, @/components/ui/badge, @/components/ui/tabs, ../hooks/useServices, ../types, ../components/SectionCard, ../integrations/registry, ./service-tabs
|
||||
- ServicePage.tsx | Displays a detailed dashboard page for a specific service instance with overview, content tabs, and widgets. | exp: func:ServicePage(), call:useParams, call:useServiceInstances, call:useNavigate, call:useMemo, call:services.find, call:getServiceBinding, call:serviceContentTabs, call:services.filter, call:binding.widgets.map, call:navigate, call:encodeURIComponent, call:siblings.map, call:contentTabs.map, call:allTabs.map | dep: react, react-router-dom, lucide-react, @/components/ui/alert, @/components/ui/badge, @/components/ui/button, @/components/ui/tabs, ../hooks/useServices, ../types, ../components/SectionCard, ../integrations/registry, ./service-tabs, @/components/ui, @/components/SectionCard
|
||||
- ServiceTypePage.tsx | Resolves the first enabled service instance for a given type and redirects to it, or shows an empty state if none are configured. | exp: func:ServiceTypePage(), call:useParams, call:useServiceInstances, call:useMemo, call:instances.find | dep: react, react-router-dom, @/components/ui/alert, @/components/ui/button, ../hooks/useServices, useServices hook
|
||||
- ServicesPage.tsx | Provides the UI and logic for configuring external service instances and managing named dashboards with pinned links. | exp: func:ServicesPage(), call:useNavigate, call:useServiceInstances, call:useServiceTypes, call:useDeleteServiceInstance, call:useState, call:useMemo, call:map.get, call:list.push, call:map.set, call:[...map.entries()].sort, call:map.entries, call:a[0].localeCompare, call:types.find, call:getServiceBinding, call:setCreateOpen, call:grouped.map, call:typeName, call:instances.map, call:Object.entries(s.secrets_set).some, call:navigate, call:setDeleteId, call:Boolean, call:deleteService.mutate | dep: react, react-router-dom, @/components/ui/alert, @/components/ui/badge, @/components/ui/button, @/components/ui/input, @/components/ui/label, @/components/ui/switch, @/components/ui/dialog, @/components/ui/select, lucide-react, ../hooks/useServices, ../hooks/useDashboards, ../types, ../components/SectionCard, ../components/ConfirmDialog, ../components/DialogFooter, ../integrations/registry, ../components/PinnedServiceLink, ../api/dashboards, @/components/ui/*, useServices, useDashboards, SectionCard, ConfirmDialog, DialogFooter, integrations/registry
|
||||
- Settings.tsx | Provides the settings page UI for managing monitoring machines, SSH keys, services, and database resets, including CRUD operations and SSH connection validation. | exp: func:Settings(), call:useMonitoringSettings, call:useSSHKeys, call:useSaveMonitoringMachine, call:useDeleteMonitoringMachine, call:useTestMonitoringMachineSSH, call:useState, call:emptyMachine, call:useIsMobile, call:useMemo, call:orderedMachines.find, call:setSSHValidationMessage, call:setSSHValidationError, call:setSSHValidationStatus, call:clearSSHValidation, call:setMachineDraft, call:setEditingMachine, call:setMachineDialogOpen, call:saveMachine.mutateAsync, call:testMachineSSH.mutateAsync, call:String, call:message.toLowerCase, call:lowered.includes, call:setTab, call:orderedMachines.map, call:setSelectedMachineId, call:cn, call:openEditMachine, call:setDeleteMachineId, call:closeMachineDialog, call:saveMachineDraft, call:machineDraft.host.trim, call:isMachineDraftDirty, call:Boolean, call:deleteMachine.mutate | dep: react, ../types, ../hooks/useSettings, ../hooks/useIsMobile, @/components/ui/sheet-form, ../components/DialogFooter, ../components/HoverEditButton, ../components/SectionCard, ../components/SelectionRailCard, ../components/TabbedCard, ../components/ConfirmDialog, @/lib/utils, @/components/ui/alert, @/components/ui/badge, @/components/ui/button, @/components/ui/card, @/components/ui/checkbox, @/components/ui/dialog, @/components/ui/input, @/components/ui/label, @/components/ui/select, @/components/ui/switch, @/components/ui/tabs, @/components/ui/textarea, ../hooks/useServices, @/components/ui/*, @/hooks/useSettings, @/hooks/useServices, @/hooks/useIsMobile
|
||||
- ServicesPage.tsx | Renders the services management page, allowing users to create, configure, test, and delete service instances, as well as manage custom dashboards composed of pinned service links. | exp: func:ServicesPage(), call:useNavigate, call:useServiceInstances, call:useServiceTypes, call:useDeleteServiceInstance, call:useState, call:useMemo, call:map.get, call:list.push, call:map.set, call:[...map.entries()].sort, call:map.entries, call:a[0].localeCompare, call:types.find, call:getServiceBinding, call:setCreateOpen, call:grouped.map, call:typeName, call:instances.map, call:Object.entries(s.secrets_set).some, call:navigate, call:setDeleteId, call:Boolean, call:deleteService.mutate | dep: react, react-router-dom, @/components/ui/alert, @/components/ui/badge, @/components/ui/button, @/components/ui/input, @/components/ui/label, @/components/ui/textarea, @/components/ui/switch, @/components/ui/dialog, @/components/ui/select, lucide-react, ../hooks/useServices, ../hooks/useDashboards, ../types, ../components/SectionCard, ../components/ConfirmDialog, ../components/DialogFooter, ../integrations/registry, ../components/ServiceTestPanel, ../components/PinnedServiceLink, ../api/dashboards, @/components/ui/*, useServices, useDashboards, SectionCard, ConfirmDialog, DialogFooter, ServiceTestPanel, getServiceBinding, serviceLinkTarget
|
||||
- Settings.tsx | Provides a settings dashboard UI for managing monitoring machines, SSH keys, service instances, and danger zone operations with CRUD forms and validation. | exp: func:Settings(), call:useMonitoringSettings, call:useSSHKeys, call:useSaveMonitoringMachine, call:useDeleteMonitoringMachine, call:useTestMonitoringMachineSSH, call:useSearchParams, call:useState, call:searchParams.get, call:emptyMachine, call:useIsMobile, call:useMemo, call:orderedMachines.find, call:setSSHValidationMessage, call:setSSHValidationError, call:setSSHValidationStatus, call:clearSSHValidation, call:setMachineDraft, call:setEditingMachine, call:setMachineDialogOpen, call:saveMachine.mutateAsync, call:testMachineSSH.mutateAsync, call:String, call:message.toLowerCase, call:lowered.includes, call:setTab, call:orderedMachines.map, call:setSelectedMachineId, call:cn, call:openEditMachine, call:setDeleteMachineId, call:closeMachineDialog, call:saveMachineDraft, call:machineDraft.host.trim, call:isMachineDraftDirty, call:Boolean, call:deleteMachine.mutate | dep: react, react-router-dom, ../types, ../hooks/useSettings, ../hooks/useIsMobile, @/components/ui/sheet-form, ../components/DialogFooter, ../components/HoverEditButton, ../components/SectionCard, ../components/SelectionRailCard, ../components/TabbedCard, ../components/ConfirmDialog, @/lib/utils, @/components/ui/alert, @/components/ui/badge, @/components/ui/button, @/components/ui/card, @/components/ui/checkbox, @/components/ui/dialog, @/components/ui/input, @/components/ui/label, @/components/ui/select, @/components/ui/switch, @/components/ui/tabs, @/components/ui/textarea, ../hooks/useServices, ../components/ServiceTestPanel, ../components (DialogFooter, SectionCard, SelectionRailCard, TabbedCard, ConfirmDialog, HoverEditButton, ServiceTestPanel), @/components/ui (multiple UI primitives)
|
||||
## arch
|
||||
React functional components using URL-parameter-driven routing, tabbed interfaces, and dialog-based CRUD management with widget composition.
|
||||
Route-level React page components using composition of widgets, dialogs, and CRUD forms with slug/ID-based data resolution and conditional rendering for empty states.
|
||||
## tags
|
||||
call:use, components, ui, call:set, service, machine, page, dashboard
|
||||
call:use, components, ui, call:set, service, machine, dashboard, page
|
||||
## symbols
|
||||
- Dashboard
|
||||
- NamedDashboardPage
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useMemo } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { Settings as SettingsIcon } from "lucide-react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useServiceInstances } from "../hooks/useServices";
|
||||
import type { ServiceInstance } from "../types";
|
||||
@@ -89,7 +91,21 @@ export function ServicePage() {
|
||||
<h2 className="text-xl font-semibold">{instance.name}</h2>
|
||||
<p className="text-sm text-muted-foreground">{binding.description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{binding.name}</Badge>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/settings?tab=services&service=${encodeURIComponent(instance.id)}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<SettingsIcon className="mr-1 h-3 w-3" />
|
||||
Settings
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Instance tabs (only when >1 enabled sibling) */}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import type {
|
||||
MonitoringMachine,
|
||||
MonitoringMachineInput,
|
||||
@@ -893,7 +894,11 @@ export function Settings() {
|
||||
const saveMachine = useSaveMonitoringMachine();
|
||||
const deleteMachine = useDeleteMonitoringMachine();
|
||||
const testMachineSSH = useTestMonitoringMachineSSH();
|
||||
const [tab, setTab] = useState<SettingsTab>("machines");
|
||||
const [searchParams] = useSearchParams();
|
||||
const [tab, setTab] = useState<SettingsTab>(
|
||||
(searchParams.get("tab") as SettingsTab | null) ?? "machines",
|
||||
);
|
||||
const initialServiceId = searchParams.get("service") ?? "";
|
||||
const [deleteMachineId, setDeleteMachineId] = useState<string | null>(null);
|
||||
const [selectedSSHKeyId, setSelectedSSHKeyId] = useState("");
|
||||
const [sshValidationMessage, setSSHValidationMessage] = useState("");
|
||||
@@ -1195,7 +1200,9 @@ export function Settings() {
|
||||
onSelectKeyId={setSelectedSSHKeyId}
|
||||
/>
|
||||
)}
|
||||
{tab === "services" && <ServicesAdminCard />}
|
||||
{tab === "services" && (
|
||||
<ServicesAdminCard initialServiceId={initialServiceId} />
|
||||
)}
|
||||
{tab === "danger" && <ResetLocalDatabaseCard />}
|
||||
</TabbedCard>
|
||||
{isMobile ? (
|
||||
@@ -1338,10 +1345,14 @@ export function Settings() {
|
||||
* old ServicePage ConfigBody — the service page is now a pure operational
|
||||
* view; all administration lives here.
|
||||
*/
|
||||
function ServicesAdminCard() {
|
||||
function ServicesAdminCard({
|
||||
initialServiceId = "",
|
||||
}: {
|
||||
initialServiceId?: string;
|
||||
}) {
|
||||
const { data: services = [] } = useServiceInstances();
|
||||
const { data: types = [] } = useServiceTypes();
|
||||
const [selectedServiceId, setSelectedServiceId] = useState("");
|
||||
const [selectedServiceId, setSelectedServiceId] = useState(initialServiceId);
|
||||
|
||||
const sortedServices = useMemo(
|
||||
() =>
|
||||
@@ -1416,6 +1427,11 @@ function ServicesAdminCard() {
|
||||
>
|
||||
{selectedService ? (
|
||||
<ServiceConfigEditor
|
||||
// Remount on service switch so useState initializers (name, config,
|
||||
// secrets) re-run for the new instance. Without this key, switching
|
||||
// services in the rail keeps the previous service's editable state
|
||||
// and a save writes stale name/config onto the new service's row.
|
||||
key={selectedService.id}
|
||||
instance={selectedService}
|
||||
typeInfo={selectedTypeInfo}
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: frontend/src/pages/__tests__
|
||||
|
||||
## role
|
||||
Test suite for top-level page components, ensuring correct rendering, user interactions, and state handling across Dashboard, NamedDashboard, Service, and Settings views.
|
||||
Test suite covering dashboard, service, and settings page components with mocked hooks and React Testing Library.
|
||||
## parent
|
||||
index: frontend/src/pages/.pi-map.index.md
|
||||
map: frontend/src/pages/.pi-map.md
|
||||
@@ -12,6 +12,7 @@ map: frontend/src/pages/.pi-map.md
|
||||
- Dashboard.test.tsx
|
||||
- NamedDashboardPage.test.tsx
|
||||
- ServicePage.test.tsx
|
||||
- Settings.services.test.tsx
|
||||
- Settings.test.tsx
|
||||
## links
|
||||
index: frontend/src/pages/__tests__/.pi-map.index.md
|
||||
|
||||
@@ -4,16 +4,17 @@ dir: frontend/src/pages/__tests__
|
||||
index: frontend/src/pages/__tests__/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Test suite for top-level page components, ensuring correct rendering, user interactions, and state handling across Dashboard, NamedDashboard, Service, and Settings views.
|
||||
Test suite covering dashboard, service, and settings page components with mocked hooks and React Testing Library.
|
||||
## files
|
||||
- Dashboard.test.tsx | Tests the Dashboard component's shortcut CRUD functionality (creation, deletion, empty state rendering) while stubbing out composed widgets and data hooks. | dep: vitest, @testing-library/react, @testing-library/user-event, ../Dashboard, ../../types, react-router-dom, Dashboard, DashboardShortcut
|
||||
- NamedDashboardPage.test.tsx | Tests the NamedDashboardPage component covering loading, error/404, data rendering, and empty states using mocked hooks. | dep: vitest, @testing-library/react, react-router-dom, @tanstack/react-query, ../NamedDashboardPage, ../../hooks/useDashboards, NamedDashboardPage, useDashboards, useWidgets, WidgetConfigDialog, WidgetInstance
|
||||
- ServicePage.test.tsx | Tests the ServicePage component's tab rendering logic, verifying conditional display of tabs based on service type and instance count. | dep: vitest, @testing-library/react, @testing-library/user-event, react-router-dom, ../ServicePage, ../../types, ServicePage, ServiceInstance
|
||||
- Settings.test.tsx | Tests the Settings component's machine list rendering, editing, and deletion flows with mocked hooks. | dep: vitest, @testing-library/react, @testing-library/user-event, ../Settings, ../../types, Settings, MonitoringMachine type, useSettings hooks
|
||||
- Settings.services.test.tsx | Regression test verifying that the Settings component correctly resets editor state when switching services to prevent saving stale data from a previously selected service. | dep: vitest, @testing-library/react, @testing-library/user-event, react-router-dom, ../Settings, ../../hooks/useSettings, ../../hooks/useServices
|
||||
- Settings.test.tsx | Tests the Settings component's machine CRUD operations (render, edit/save, delete) using mocked hooks and React Testing Library. | dep: vitest, @testing-library/react, @testing-library/user-event, react-router-dom, ../Settings, ../../types, Settings, MonitoringMachine
|
||||
## arch
|
||||
React Testing Library with Jest, using mocked hooks and stubbed child components to isolate page-level rendering logic and CRUD workflows.
|
||||
Standard React Testing Library pattern using mocked hooks/data sources, component rendering verification, and user interaction simulation across isolated test files per page component.
|
||||
## tags
|
||||
react, @testing, library, dashboard, tests, component, rendering, hooks
|
||||
react, library, @testing, hooks, dashboard, component, vitest, router
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Regression test for the service-editor state-reset bug.
|
||||
*
|
||||
* ServiceConfigEditor initializes its editable state (name, config, secrets)
|
||||
* from the `instance` prop via useState. The parent must remount it (via
|
||||
* `key={instance.id}`) when the selected service changes — otherwise switching
|
||||
* services in the rail keeps the PREVIOUS service's name/config in state while
|
||||
* `instance.id`/`service_type` (read live from props) now point at the new one.
|
||||
* A save then wrote the stale name/config onto the new service's row
|
||||
* (e.g. saving qBittorrent renamed it "Jellyfin" with Jellyfin's URL).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { Settings } from "../Settings";
|
||||
|
||||
const saveServiceMutate = vi.fn().mockResolvedValue({});
|
||||
|
||||
// Settings invokes the machine/SSH hooks at the top level regardless of active
|
||||
// tab, so stub them out to keep the render focused on the Services editor.
|
||||
vi.mock("../../hooks/useSettings", () => ({
|
||||
useMonitoringSettings: () => ({ data: [] }),
|
||||
useSSHKeys: () => ({ data: [] }),
|
||||
useSaveMonitoringMachine: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
useDeleteMonitoringMachine: () => ({ mutate: vi.fn() }),
|
||||
useTestMonitoringMachineSSH: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
useResetLocalDatabase: () => ({}),
|
||||
useSaveSSHKey: () => ({ mutateAsync: vi.fn() }),
|
||||
useGenerateSSHKey: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
useDeleteSSHKey: () => ({ mutate: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useServices", () => ({
|
||||
useServiceTypes: () => ({
|
||||
data: [
|
||||
{
|
||||
service_type: "jellyfin",
|
||||
name: "Jellyfin",
|
||||
description: "Media server",
|
||||
config_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
base_url: { type: "string", description: "URL" },
|
||||
timeout_seconds: { type: "integer" },
|
||||
},
|
||||
},
|
||||
secret_fields: [{ key: "api_key", label: "API key", required: true }],
|
||||
widget_kinds: [],
|
||||
},
|
||||
{
|
||||
service_type: "qbittorrent",
|
||||
name: "qBittorrent",
|
||||
description: "Torrent client",
|
||||
config_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
base_url: { type: "string" },
|
||||
timeout_seconds: { type: "integer" },
|
||||
},
|
||||
},
|
||||
secret_fields: [
|
||||
{ key: "username", label: "Username", required: true },
|
||||
{ key: "password", label: "Password", required: true },
|
||||
],
|
||||
widget_kinds: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
useServiceInstances: () => ({
|
||||
data: [
|
||||
{
|
||||
id: "svc-jf",
|
||||
service_type: "jellyfin",
|
||||
name: "My Jellyfin",
|
||||
config: { base_url: "https://jf.example.com", timeout_seconds: 60 },
|
||||
secrets_set: { api_key: true },
|
||||
enabled: true,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
},
|
||||
{
|
||||
id: "svc-qb",
|
||||
service_type: "qbittorrent",
|
||||
name: "My qBittorrent",
|
||||
config: { base_url: "https://qb.example.com", timeout_seconds: 60 },
|
||||
secrets_set: { username: true, password: true },
|
||||
enabled: true,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
},
|
||||
],
|
||||
}),
|
||||
useSaveServiceInstance: () => ({
|
||||
mutateAsync: saveServiceMutate,
|
||||
isPending: false,
|
||||
}),
|
||||
useDeleteServiceInstance: () => ({ mutate: vi.fn() }),
|
||||
useTestServiceInstance: () => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({ ok: true }),
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
saveServiceMutate.mockClear();
|
||||
});
|
||||
|
||||
describe("Settings > Services editor", () => {
|
||||
it("resets editable state when switching services so save does not clobber (regression)", async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/settings?tab=services"]}>
|
||||
<Settings />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// Default selection is the first service (Jellyfin). Switch to qBittorrent
|
||||
// via the selection rail.
|
||||
await userEvent.click(screen.getByText("My qBittorrent"));
|
||||
|
||||
// Save is gated on a passed test or "Save anyway"; toggle Save anyway so
|
||||
// we can save without exercising the (mocked) test mutation.
|
||||
await userEvent.click(screen.getByLabelText(/save anyway/i));
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
expect(saveServiceMutate).toHaveBeenCalledTimes(1);
|
||||
const saved = saveServiceMutate.mock.calls[0][0];
|
||||
// Must target qBittorrent's id AND carry qBittorrent's own name/config —
|
||||
// not the stale Jellyfin values from the previously selected service.
|
||||
expect(saved.id).toBe("svc-qb");
|
||||
expect(saved.service_type).toBe("qbittorrent");
|
||||
expect(saved.name).toBe("My qBittorrent");
|
||||
expect(saved.config).toEqual({
|
||||
base_url: "https://qb.example.com",
|
||||
timeout_seconds: 60,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,16 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { Settings } from "../Settings";
|
||||
import type { MonitoringMachine } from "../../types";
|
||||
|
||||
const saveMachineMutate = vi.fn().mockResolvedValue({});
|
||||
const deleteMachineMutate = vi.fn();
|
||||
const testSSHMutate = vi
|
||||
.fn()
|
||||
.mockResolvedValue({
|
||||
const testSSHMutate = vi.fn().mockResolvedValue({
|
||||
message: "SSH auth succeeded",
|
||||
known_hosts_updated: true,
|
||||
});
|
||||
});
|
||||
|
||||
let machines: MonitoringMachine[] = [];
|
||||
|
||||
@@ -66,14 +65,21 @@ beforeEach(() => {
|
||||
describe("Settings", () => {
|
||||
it("renders the machine list from the mocked store", () => {
|
||||
machines = [localMachine()];
|
||||
render(<Settings />);
|
||||
// The rail row caption (mode · enabled) is unique to the selection rail.
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Settings />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(screen.getByText("local · Enabled")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("saves a machine via the editor dialog (controlled useState parity)", async () => {
|
||||
machines = [localMachine()];
|
||||
render(<Settings />);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Settings />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// The detail-pane "Edit" has visible text "Edit"; the rail hover edit
|
||||
// affordance is icon-only (aria-label "Edit") — disambiguate by text.
|
||||
@@ -99,7 +105,11 @@ describe("Settings", () => {
|
||||
|
||||
it("deletes a machine through the confirm dialog", async () => {
|
||||
machines = [localMachine()];
|
||||
render(<Settings />);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Settings />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// Detail-pane "Delete" opens the confirm dialog.
|
||||
await userEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||
|
||||
Reference in New Issue
Block a user