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
|
dir: frontend/src/pages
|
||||||
|
|
||||||
## role
|
## 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
|
## parent
|
||||||
index: frontend/src/.pi-map.index.md
|
index: frontend/src/.pi-map.index.md
|
||||||
map: frontend/src/.pi-map.md
|
map: frontend/src/.pi-map.md
|
||||||
|
|||||||
@@ -4,18 +4,18 @@ dir: frontend/src/pages
|
|||||||
index: frontend/src/pages/.pi-map.index.md
|
index: frontend/src/pages/.pi-map.index.md
|
||||||
|
|
||||||
## role
|
## 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
|
## 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/*
|
- 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
|
- 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
|
- 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
|
- 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 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
|
- 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
|
## 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
|
## tags
|
||||||
call:use, components, ui, call:set, service, machine, page, dashboard
|
call:use, components, ui, call:set, service, machine, dashboard, page
|
||||||
## symbols
|
## symbols
|
||||||
- Dashboard
|
- Dashboard
|
||||||
- NamedDashboardPage
|
- NamedDashboardPage
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
|
import { Settings as SettingsIcon } from "lucide-react";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { useServiceInstances } from "../hooks/useServices";
|
import { useServiceInstances } from "../hooks/useServices";
|
||||||
import type { ServiceInstance } from "../types";
|
import type { ServiceInstance } from "../types";
|
||||||
@@ -89,7 +91,21 @@ export function ServicePage() {
|
|||||||
<h2 className="text-xl font-semibold">{instance.name}</h2>
|
<h2 className="text-xl font-semibold">{instance.name}</h2>
|
||||||
<p className="text-sm text-muted-foreground">{binding.description}</p>
|
<p className="text-sm text-muted-foreground">{binding.description}</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline">{binding.name}</Badge>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Instance tabs (only when >1 enabled sibling) */}
|
{/* Instance tabs (only when >1 enabled sibling) */}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
import type {
|
import type {
|
||||||
MonitoringMachine,
|
MonitoringMachine,
|
||||||
MonitoringMachineInput,
|
MonitoringMachineInput,
|
||||||
@@ -893,7 +894,11 @@ export function Settings() {
|
|||||||
const saveMachine = useSaveMonitoringMachine();
|
const saveMachine = useSaveMonitoringMachine();
|
||||||
const deleteMachine = useDeleteMonitoringMachine();
|
const deleteMachine = useDeleteMonitoringMachine();
|
||||||
const testMachineSSH = useTestMonitoringMachineSSH();
|
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 [deleteMachineId, setDeleteMachineId] = useState<string | null>(null);
|
||||||
const [selectedSSHKeyId, setSelectedSSHKeyId] = useState("");
|
const [selectedSSHKeyId, setSelectedSSHKeyId] = useState("");
|
||||||
const [sshValidationMessage, setSSHValidationMessage] = useState("");
|
const [sshValidationMessage, setSSHValidationMessage] = useState("");
|
||||||
@@ -1195,7 +1200,9 @@ export function Settings() {
|
|||||||
onSelectKeyId={setSelectedSSHKeyId}
|
onSelectKeyId={setSelectedSSHKeyId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{tab === "services" && <ServicesAdminCard />}
|
{tab === "services" && (
|
||||||
|
<ServicesAdminCard initialServiceId={initialServiceId} />
|
||||||
|
)}
|
||||||
{tab === "danger" && <ResetLocalDatabaseCard />}
|
{tab === "danger" && <ResetLocalDatabaseCard />}
|
||||||
</TabbedCard>
|
</TabbedCard>
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
@@ -1338,10 +1345,14 @@ export function Settings() {
|
|||||||
* old ServicePage ConfigBody — the service page is now a pure operational
|
* old ServicePage ConfigBody — the service page is now a pure operational
|
||||||
* view; all administration lives here.
|
* view; all administration lives here.
|
||||||
*/
|
*/
|
||||||
function ServicesAdminCard() {
|
function ServicesAdminCard({
|
||||||
|
initialServiceId = "",
|
||||||
|
}: {
|
||||||
|
initialServiceId?: string;
|
||||||
|
}) {
|
||||||
const { data: services = [] } = useServiceInstances();
|
const { data: services = [] } = useServiceInstances();
|
||||||
const { data: types = [] } = useServiceTypes();
|
const { data: types = [] } = useServiceTypes();
|
||||||
const [selectedServiceId, setSelectedServiceId] = useState("");
|
const [selectedServiceId, setSelectedServiceId] = useState(initialServiceId);
|
||||||
|
|
||||||
const sortedServices = useMemo(
|
const sortedServices = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -1416,6 +1427,11 @@ function ServicesAdminCard() {
|
|||||||
>
|
>
|
||||||
{selectedService ? (
|
{selectedService ? (
|
||||||
<ServiceConfigEditor
|
<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}
|
instance={selectedService}
|
||||||
typeInfo={selectedTypeInfo}
|
typeInfo={selectedTypeInfo}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
dir: frontend/src/pages/__tests__
|
dir: frontend/src/pages/__tests__
|
||||||
|
|
||||||
## role
|
## 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
|
## parent
|
||||||
index: frontend/src/pages/.pi-map.index.md
|
index: frontend/src/pages/.pi-map.index.md
|
||||||
map: frontend/src/pages/.pi-map.md
|
map: frontend/src/pages/.pi-map.md
|
||||||
@@ -12,6 +12,7 @@ map: frontend/src/pages/.pi-map.md
|
|||||||
- Dashboard.test.tsx
|
- Dashboard.test.tsx
|
||||||
- NamedDashboardPage.test.tsx
|
- NamedDashboardPage.test.tsx
|
||||||
- ServicePage.test.tsx
|
- ServicePage.test.tsx
|
||||||
|
- Settings.services.test.tsx
|
||||||
- Settings.test.tsx
|
- Settings.test.tsx
|
||||||
## links
|
## links
|
||||||
index: frontend/src/pages/__tests__/.pi-map.index.md
|
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
|
index: frontend/src/pages/__tests__/.pi-map.index.md
|
||||||
|
|
||||||
## role
|
## 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
|
## 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
|
- 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
|
- 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
|
- 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
|
## 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
|
## tags
|
||||||
react, @testing, library, dashboard, tests, component, rendering, hooks
|
react, library, @testing, hooks, dashboard, component, vitest, router
|
||||||
## symbols
|
## symbols
|
||||||
-
|
-
|
||||||
## workflows
|
## 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 { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { Settings } from "../Settings";
|
import { Settings } from "../Settings";
|
||||||
import type { MonitoringMachine } from "../../types";
|
import type { MonitoringMachine } from "../../types";
|
||||||
|
|
||||||
const saveMachineMutate = vi.fn().mockResolvedValue({});
|
const saveMachineMutate = vi.fn().mockResolvedValue({});
|
||||||
const deleteMachineMutate = vi.fn();
|
const deleteMachineMutate = vi.fn();
|
||||||
const testSSHMutate = vi
|
const testSSHMutate = vi.fn().mockResolvedValue({
|
||||||
.fn()
|
message: "SSH auth succeeded",
|
||||||
.mockResolvedValue({
|
known_hosts_updated: true,
|
||||||
message: "SSH auth succeeded",
|
});
|
||||||
known_hosts_updated: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
let machines: MonitoringMachine[] = [];
|
let machines: MonitoringMachine[] = [];
|
||||||
|
|
||||||
@@ -66,14 +65,21 @@ beforeEach(() => {
|
|||||||
describe("Settings", () => {
|
describe("Settings", () => {
|
||||||
it("renders the machine list from the mocked store", () => {
|
it("renders the machine list from the mocked store", () => {
|
||||||
machines = [localMachine()];
|
machines = [localMachine()];
|
||||||
render(<Settings />);
|
render(
|
||||||
// The rail row caption (mode · enabled) is unique to the selection rail.
|
<MemoryRouter>
|
||||||
|
<Settings />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
expect(screen.getByText("local · Enabled")).toBeInTheDocument();
|
expect(screen.getByText("local · Enabled")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("saves a machine via the editor dialog (controlled useState parity)", async () => {
|
it("saves a machine via the editor dialog (controlled useState parity)", async () => {
|
||||||
machines = [localMachine()];
|
machines = [localMachine()];
|
||||||
render(<Settings />);
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<Settings />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
// The detail-pane "Edit" has visible text "Edit"; the rail hover edit
|
// The detail-pane "Edit" has visible text "Edit"; the rail hover edit
|
||||||
// affordance is icon-only (aria-label "Edit") — disambiguate by text.
|
// 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 () => {
|
it("deletes a machine through the confirm dialog", async () => {
|
||||||
machines = [localMachine()];
|
machines = [localMachine()];
|
||||||
render(<Settings />);
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<Settings />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
// Detail-pane "Delete" opens the confirm dialog.
|
// Detail-pane "Delete" opens the confirm dialog.
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Delete" }));
|
await userEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||||
|
|||||||
Reference in New Issue
Block a user