diff --git a/panel/src/app/(dashboard)/layout.tsx b/panel/src/app/(dashboard)/layout.tsx index 8b476bb4..222993c5 100644 --- a/panel/src/app/(dashboard)/layout.tsx +++ b/panel/src/app/(dashboard)/layout.tsx @@ -4,6 +4,7 @@ import { Header } from "@/components/layout/header"; import { BottomTabBar } from "@/components/layout/bottom-tab-bar"; import { ScrollRestoration } from "@/components/scroll-restoration"; import { RateLimitBanner } from "@/components/rate-limit/rate-limit-banner"; +import { AutoRefreshDriver } from "@/components/providers/auto-refresh-driver"; export default function DashboardLayout({ children, @@ -14,6 +15,7 @@ export default function DashboardLayout({ // h-dvh (not h-screen/100vh): mobile Safari's dynamic toolbar resizes the // viewport, and 100vh doesn't track that — dvh does.
+
diff --git a/panel/src/app/(dashboard)/settings/__tests__/page.test.tsx b/panel/src/app/(dashboard)/settings/__tests__/page.test.tsx index 7890b12f..f698a3bc 100644 --- a/panel/src/app/(dashboard)/settings/__tests__/page.test.tsx +++ b/panel/src/app/(dashboard)/settings/__tests__/page.test.tsx @@ -1,28 +1,30 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import type { ReactNode } from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; -const { getAll, update } = vi.hoisted(() => ({ - getAll: vi.fn(async () => ({ - notifications_enabled: "false", - sound_enabled: "false", - auto_refresh: "false", - refresh_interval: "45", - })), - update: vi.fn(async () => ({})), +// The four prefs below are CLIENT-ONLY (never sent to the backend — the +// server's settings allowlist is transcript_retention_days + feature flags +// only, see roboco/services/settings.py). This mock stands in for the +// persisted UI store; mutate its fields per-test to control what the page +// renders. +const mockStore = vi.hoisted(() => ({ + sidebarCollapsed: false, + setSidebarCollapsed: vi.fn(), + notificationsEnabled: true, + setNotificationsEnabled: vi.fn(), + soundEnabled: true, + setSoundEnabled: vi.fn(), + autoRefresh: false, + setAutoRefresh: vi.fn(), + refreshIntervalSeconds: 30, + setRefreshIntervalSeconds: vi.fn(), })); -vi.mock("@/lib/api", () => ({ settingsApi: { getAll, update } })); +vi.mock("@/store", () => ({ useUIStore: () => mockStore })); vi.mock("next-themes", () => ({ useTheme: () => ({ theme: "dark", setTheme: vi.fn() }), })); -vi.mock("@/store", () => ({ - useUIStore: () => ({ sidebarCollapsed: false, setSidebarCollapsed: vi.fn() }), -})); - vi.mock("@/components/settings/transcript-retention-card", () => ({ TranscriptRetentionCard: () => null, })); @@ -31,19 +33,8 @@ vi.mock("@/components/settings/feature-flags-card", () => ({ FeatureFlagsCard: () => null, })); -vi.mock("sonner", () => ({ - toast: { success: vi.fn(), error: vi.fn() }, -})); - import SettingsPage from "../page"; -function withQueryClient(ui: ReactNode) { - const client = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); - return {ui}; -} - // The Label and Switch/Select are siblings inside a flex row, so the label // text doesn't associate with the control. Walk to the row to find it. function controlFor(labelText: RegExp | string, role: string): HTMLElement { @@ -55,46 +46,74 @@ function controlFor(labelText: RegExp | string, role: string): HTMLElement { return el as HTMLElement; } -describe("SettingsPage — Save persists prefs via settingsApi (H16)", () => { +function resetStore() { + mockStore.sidebarCollapsed = false; + mockStore.notificationsEnabled = true; + mockStore.soundEnabled = true; + mockStore.autoRefresh = false; + mockStore.refreshIntervalSeconds = 30; + for (const fn of [ + mockStore.setSidebarCollapsed, + mockStore.setNotificationsEnabled, + mockStore.setSoundEnabled, + mockStore.setAutoRefresh, + mockStore.setRefreshIntervalSeconds, + ]) { + fn.mockReset(); + } +} + +describe("SettingsPage — client-only prefs (store-driven, no server round trip)", () => { beforeEach(() => { - getAll.mockReset(); - update.mockReset(); - getAll.mockResolvedValue({ - notifications_enabled: "false", - sound_enabled: "false", - auto_refresh: "false", - refresh_interval: "45", - }); - update.mockResolvedValue({}); + resetStore(); }); - it("initializes the prefs from the server, not the hardcoded defaults", async () => { - render(withQueryClient()); + it("has no Save Settings button — every pref is instant-apply", () => { + render(); + expect( + screen.queryByRole("button", { name: /save settings/i }), + ).not.toBeInTheDocument(); + }); - await waitFor(() => - expect(controlFor("Enable Notifications", "switch")).not.toBeChecked(), - ); + it("renders the four prefs from the store", () => { + mockStore.notificationsEnabled = false; + mockStore.soundEnabled = false; + mockStore.autoRefresh = true; + mockStore.refreshIntervalSeconds = 60; + render(); + + expect(controlFor("Enable Notifications", "switch")).not.toBeChecked(); expect(controlFor("Sound Alerts", "switch")).not.toBeChecked(); - expect(controlFor("Auto Refresh", "switch")).not.toBeChecked(); - // refresh_interval "45" overrides the hardcoded "30s" default. - expect(controlFor("Refresh Interval", "combobox")).not.toHaveTextContent( - "30s", + expect(controlFor("Auto Refresh", "switch")).toBeChecked(); + expect(controlFor("Refresh Interval", "combobox")).toHaveTextContent( + "1m", ); }); - it("persists all four prefs when Save Settings is clicked", async () => { - render(withQueryClient()); + it("toggling Auto Refresh calls setAutoRefresh directly — no edits/save step", () => { + render(); + fireEvent.click(controlFor("Auto Refresh", "switch")); + expect(mockStore.setAutoRefresh).toHaveBeenCalledWith(true); + }); - await waitFor(() => - expect(controlFor("Enable Notifications", "switch")).not.toBeChecked(), - ); + it("toggling Enable Notifications calls setNotificationsEnabled directly", () => { + render(); + fireEvent.click(controlFor("Enable Notifications", "switch")); + expect(mockStore.setNotificationsEnabled).toHaveBeenCalledWith(false); + }); - fireEvent.click(screen.getByRole("button", { name: /save settings/i })); + it("Refresh Interval select is disabled while Auto Refresh is off", () => { + render(); + expect(controlFor("Refresh Interval", "combobox")).toBeDisabled(); + }); - await waitFor(() => expect(update).toHaveBeenCalledTimes(4)); - expect(update).toHaveBeenCalledWith("notifications_enabled", "false"); - expect(update).toHaveBeenCalledWith("sound_enabled", "false"); - expect(update).toHaveBeenCalledWith("auto_refresh", "false"); - expect(update).toHaveBeenCalledWith("refresh_interval", "45"); + it("Sound Alerts switch stays disabled — and inert — when notifications are off", () => { + mockStore.notificationsEnabled = false; + render(); + const soundSwitch = controlFor("Sound Alerts", "switch"); + expect(soundSwitch).toBeDisabled(); + + fireEvent.click(soundSwitch); + expect(mockStore.setSoundEnabled).not.toHaveBeenCalled(); }); }); diff --git a/panel/src/app/(dashboard)/settings/page.tsx b/panel/src/app/(dashboard)/settings/page.tsx index 8cb59ca2..a8b47341 100644 --- a/panel/src/app/(dashboard)/settings/page.tsx +++ b/panel/src/app/(dashboard)/settings/page.tsx @@ -1,10 +1,7 @@ "use client"; -import { useState } from "react"; import { useTheme } from "next-themes"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useUIStore } from "@/store"; -import { settingsApi } from "@/lib/api"; import { Card, CardContent, @@ -14,7 +11,6 @@ import { } from "@/components/ui/card"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; -import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; import { Select, @@ -24,85 +20,25 @@ import { SelectValue, } from "@/components/ui/select"; import { Separator } from "@/components/ui/separator"; -import { Settings, Palette, Bell, Server, User, Save } from "lucide-react"; -import { toast } from "sonner"; +import { Settings, Palette, Bell, Server, User } from "lucide-react"; import { API_URL, WS_URL } from "@/lib/constants"; import { TranscriptRetentionCard } from "@/components/settings/transcript-retention-card"; import { FeatureFlagsCard } from "@/components/settings/feature-flags-card"; -// Settings keys persisted server-side (string values: "true"/"false" or a number). -const KEYS = { - notifications: "notifications_enabled", - sound: "sound_enabled", - autoRefresh: "auto_refresh", - refreshInterval: "refresh_interval", -} as const; - export default function SettingsPage() { const { theme, setTheme } = useTheme(); - const { sidebarCollapsed, setSidebarCollapsed } = useUIStore(); - const queryClient = useQueryClient(); - - const { data: settings } = useQuery({ - queryKey: ["settings"], - queryFn: settingsApi.getAll, - }); - - // `edits` holds the user's in-progress changes; an unset field means "show - // the server value" (or the hardcoded default before the query loads). - // Deriving the displayed value avoids syncing query state into local state - // via an effect (react-hooks/set-state-in-effect). - const [edits, setEdits] = useState<{ - notifications?: boolean; - sound?: boolean; - autoRefresh?: boolean; - refreshInterval?: string; - }>({}); - - const notificationsEnabled = - edits.notifications ?? - (settings?.[KEYS.notifications] === undefined - ? true - : settings[KEYS.notifications] === "true"); - const soundEnabled = - edits.sound ?? - (settings?.[KEYS.sound] === undefined - ? true - : settings[KEYS.sound] === "true"); - const autoRefresh = - edits.autoRefresh ?? - (settings?.[KEYS.autoRefresh] === undefined - ? true - : settings[KEYS.autoRefresh] === "true"); - const refreshInterval = - edits.refreshInterval ?? - (settings?.[KEYS.refreshInterval] === undefined - ? "30" - : settings[KEYS.refreshInterval]); - - const saveMutation = useMutation({ - mutationFn: async () => { - await settingsApi.update( - KEYS.notifications, - String(notificationsEnabled), - ); - await settingsApi.update(KEYS.sound, String(soundEnabled)); - await settingsApi.update(KEYS.autoRefresh, String(autoRefresh)); - await settingsApi.update(KEYS.refreshInterval, refreshInterval); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["settings"] }); - setEdits({}); // re-sync to the freshly-saved server values - toast.success("Settings saved successfully"); - }, - onError: (error) => { - toast.error( - `Failed to save: ${error instanceof Error ? error.message : "Unknown error"}`, - ); - }, - }); - - const handleSave = () => saveMutation.mutate(); + const { + sidebarCollapsed, + setSidebarCollapsed, + notificationsEnabled, + setNotificationsEnabled, + soundEnabled, + setSoundEnabled, + autoRefresh, + setAutoRefresh, + refreshIntervalSeconds, + setRefreshIntervalSeconds, + } = useUIStore(); return (
@@ -193,7 +129,8 @@ export default function SettingsPage() { - {/* Data & Refresh */} + {/* Data & Refresh — client-only prefs, instant-apply (same idiom as + Theme/Sidebar above); never sent to the backend. */} @@ -207,15 +144,10 @@ export default function SettingsPage() {

- Automatically refresh data periodically + Periodically re-fetch the current page's data

- - setEdits((e) => ({ ...e, autoRefresh: v })) - } - /> +
@@ -226,10 +158,8 @@ export default function SettingsPage() {