diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1d13d6e..1acf369 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -20,6 +20,7 @@ import { LoginPage } from '@/components/LoginPage' import { NodeModal } from '@/components/modals/NodeModal' import { EdgeModal } from '@/components/modals/EdgeModal' import { ScanConfigModal } from '@/components/modals/ScanConfigModal' +import { SettingsModal } from '@/components/modals/SettingsModal' import { ZigbeeImportModal } from '@/components/zigbee/ZigbeeImportModal' import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal' import { TextModal, type TextFormData } from '@/components/modals/TextModal' @@ -69,6 +70,7 @@ export default function App() { const [pendingConnection, setPendingConnection] = useState(null) const [editEdgeId, setEditEdgeId] = useState(null) const [scanConfigOpen, setScanConfigOpen] = useState(false) + const [settingsOpen, setSettingsOpen] = useState(false) const [exportModalOpen, setExportModalOpen] = useState(false) const [zigbeeImportOpen, setZigbeeImportOpen] = useState(false) @@ -608,6 +610,7 @@ export default function App() { onScan={() => setScanConfigOpen(true)} onZigbeeImport={() => setZigbeeImportOpen(true)} onSave={handleSave} + onOpenSettings={() => setSettingsOpen(true)} forceView={sidebarForceView} onOpenPending={openPendingModal} /> @@ -805,6 +808,10 @@ export default function App() { /> setShortcutsOpen(false)} /> + {!STANDALONE && ( + setSettingsOpen(false)} /> + )} + setPendingModalOpen(false)} diff --git a/frontend/src/components/modals/SettingsModal.tsx b/frontend/src/components/modals/SettingsModal.tsx new file mode 100644 index 0000000..e3cba24 --- /dev/null +++ b/frontend/src/components/modals/SettingsModal.tsx @@ -0,0 +1,128 @@ +import { useState, useEffect } from 'react' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { settingsApi } from '@/api/client' +import { toast } from 'sonner' +import { + type AlignmentSettings, + readAlignmentSettings, + writeAlignmentSettings, + subscribeAlignmentSettings, +} from '@/utils/alignmentSettings' + +interface SettingsModalProps { + open: boolean + onClose: () => void +} + +export function SettingsModal({ open, onClose }: SettingsModalProps) { + const [interval, setIntervalValue] = useState(60) + const [saving, setSaving] = useState(false) + const [alignment, setAlignment] = useState(readAlignmentSettings) + + useEffect(() => { + if (!open) return + settingsApi.get() + .then((res) => setIntervalValue(res.data.interval_seconds)) + .catch(() => {/* use default */}) + }, [open]) + + useEffect(() => subscribeAlignmentSettings(setAlignment), []) + + const updateAlignment = (patch: Partial) => { + const next = { ...alignment, ...patch } + setAlignment(next) + writeAlignmentSettings(next) + } + + const handleSave = async () => { + setSaving(true) + try { + await settingsApi.save({ interval_seconds: interval }) + toast.success('Settings saved') + onClose() + } catch { + toast.error('Failed to save settings') + } finally { + setSaving(false) + } + } + + return ( + !v && onClose()}> + + + Settings + + +
+ {/* Status checker */} +
+ +
+ { const v = Number(e.target.value); if (!isNaN(v)) setIntervalValue(v) }} + className="w-24 px-2 py-1 rounded-md text-xs font-mono bg-[#0d1117] border border-border text-foreground focus:outline-none focus:border-[#00d4ff]" + /> + seconds +
+

+ How often node health is polled (ping, HTTP, SSH…) +

+
+ + {/* Canvas */} +
+ Canvas + + + +
+ +
+ updateAlignment({ threshold: Number(e.target.value) })} + className="flex-1 cursor-pointer accent-[#00d4ff]" + aria-label="Alignment snap threshold" + /> + {alignment.threshold}px +
+

+ Distance at which dragged nodes snap to neighbours. Hold Alt while dragging to disable. +

+
+
+
+ + + + + +
+
+ ) +} diff --git a/frontend/src/components/modals/__tests__/SettingsModal.test.tsx b/frontend/src/components/modals/__tests__/SettingsModal.test.tsx new file mode 100644 index 0000000..40763a8 --- /dev/null +++ b/frontend/src/components/modals/__tests__/SettingsModal.test.tsx @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { SettingsModal } from '../SettingsModal' + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) +vi.mock('@/api/client', () => ({ + settingsApi: { + get: vi.fn(), + save: vi.fn(), + }, +})) + +import { settingsApi } from '@/api/client' +import { toast } from 'sonner' + +describe('SettingsModal', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(settingsApi.get).mockResolvedValue({ data: { interval_seconds: 60 } } as never) + vi.mocked(settingsApi.save).mockResolvedValue({ data: { interval_seconds: 60 } } as never) + vi.mocked(toast.success).mockReset() + vi.mocked(toast.error).mockReset() + }) + + it('loads interval from API when opened', async () => { + render() + await waitFor(() => expect(settingsApi.get).toHaveBeenCalledOnce()) + expect(screen.getByText('Status check interval (s)')).toBeDefined() + }) + + it('does not fetch when closed', () => { + render() + expect(settingsApi.get).not.toHaveBeenCalled() + }) + + it('displays interval loaded from API', async () => { + vi.mocked(settingsApi.get).mockResolvedValue({ data: { interval_seconds: 120 } } as never) + render() + expect(await screen.findByDisplayValue('120')).toBeDefined() + }) + + it('saves interval and closes on Save click', async () => { + const onClose = vi.fn() + render() + const input = await screen.findByDisplayValue('60') + fireEvent.change(input, { target: { value: '180' } }) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => { + expect(settingsApi.save).toHaveBeenCalledWith({ interval_seconds: 180 }) + expect(toast.success).toHaveBeenCalledWith('Settings saved') + expect(onClose).toHaveBeenCalled() + }) + }) + + it('shows error toast and stays open when save fails', async () => { + vi.mocked(settingsApi.save).mockRejectedValue(new Error('network')) + const onClose = vi.fn() + render() + await screen.findByDisplayValue('60') + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('Failed to save settings') + }) + expect(onClose).not.toHaveBeenCalled() + }) + + it('calls onClose on Cancel', async () => { + const onClose = vi.fn() + render() + await screen.findByDisplayValue('60') + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(onClose).toHaveBeenCalledOnce() + }) +}) diff --git a/frontend/src/components/panels/Sidebar.tsx b/frontend/src/components/panels/Sidebar.tsx index effa9cc..74f8ed8 100644 --- a/frontend/src/components/panels/Sidebar.tsx +++ b/frontend/src/components/panels/Sidebar.tsx @@ -5,22 +5,16 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { useCanvasStore } from '@/stores/canvasStore' import { useDesignStore } from '@/stores/designStore' import { useAuthStore } from '@/stores/authStore' -import { designsApi, scanApi, settingsApi } from '@/api/client' +import { designsApi, scanApi } from '@/api/client' import { resolveDesignIcon, DEFAULT_DESIGN_ICON } from '@/utils/designIcons' import { DesignModal, type DesignFormData } from '@/components/modals/DesignModal' import type { Design } from '@/types' import { toast } from 'sonner' import { useLatestRelease } from '@/hooks/useLatestRelease' -import { - type AlignmentSettings, - readAlignmentSettings, - writeAlignmentSettings, - subscribeAlignmentSettings, -} from '@/utils/alignmentSettings' const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' -type SidebarView = 'canvas' | 'history' | 'settings' +type SidebarView = 'canvas' | 'history' const PENDING_TRIGGERS: { kind: 'pending' | 'hidden'; icon: typeof ScanLine; label: string }[] = [ { kind: 'pending', icon: ScanLine, label: 'Pending Devices' }, @@ -45,11 +39,12 @@ interface SidebarProps { onScan: () => void onZigbeeImport: () => void onSave: () => void + onOpenSettings: () => void forceView?: SidebarView onOpenPending: (deviceId?: string, status?: 'pending' | 'hidden') => void } -export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbeeImport, onSave, forceView, onOpenPending }: SidebarProps) { +export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbeeImport, onSave, onOpenSettings, forceView, onOpenPending }: SidebarProps) { const [collapsed, setCollapsed] = useState(false) const [activeView, setActiveView] = useState(forceView ?? 'canvas') const [prevForceView, setPrevForceView] = useState(forceView) @@ -225,7 +220,6 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee {!collapsed && activeView !== 'canvas' && (
{activeView === 'history' && } - {activeView === 'settings' && }
)} @@ -279,8 +273,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee icon={Settings} label="Settings" collapsed={collapsed} - active={activeView === 'settings'} - onClick={() => setActiveView((v) => v === 'settings' ? 'canvas' : 'settings')} + onClick={onOpenSettings} /> )} {!STANDALONE && ( @@ -440,105 +433,6 @@ function ScanHistoryPanel() { ) } -function SettingsPanel() { - const [interval, setIntervalValue] = useState(60) - const [saving, setSaving] = useState(false) - const [alignment, setAlignment] = useState(readAlignmentSettings) - - useEffect(() => { - settingsApi.get() - .then((res) => setIntervalValue(res.data.interval_seconds)) - .catch(() => {/* use default */}) - }, []) - - useEffect(() => subscribeAlignmentSettings(setAlignment), []) - - const updateAlignment = (patch: Partial) => { - const next = { ...alignment, ...patch } - setAlignment(next) - writeAlignmentSettings(next) - } - - const handleSave = async () => { - setSaving(true) - try { - await settingsApi.save({ interval_seconds: interval }) - toast.success('Settings saved') - } catch { - toast.error('Failed to save settings') - } finally { - setSaving(false) - } - } - - return ( -
- Settings - -
- -
- { const v = Number(e.target.value); if (!isNaN(v)) setIntervalValue(v) }} - className="w-24 px-2 py-1 rounded-md text-xs font-mono bg-[#0d1117] border border-border text-foreground focus:outline-none focus:border-[#00d4ff]" - /> - seconds -
-

- How often node health is polled (ping, HTTP, SSH…) -

-
- - - -
- Canvas - - - -
- -
- updateAlignment({ threshold: Number(e.target.value) })} - className="flex-1 cursor-pointer accent-[#00d4ff]" - aria-label="Alignment snap threshold" - /> - {alignment.threshold}px -
-

- Distance at which dragged nodes snap to neighbours. Hold Alt while dragging to disable. -

-
-
-
- ) -} - function VersionBadge() { const current = __APP_VERSION__ const { latest, hasUpdate } = useLatestRelease(current) diff --git a/frontend/src/components/panels/__tests__/SettingsPanel.test.tsx b/frontend/src/components/panels/__tests__/SettingsPanel.test.tsx deleted file mode 100644 index 08a4a04..0000000 --- a/frontend/src/components/panels/__tests__/SettingsPanel.test.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, fireEvent, waitFor } from '@testing-library/react' -import { Sidebar } from '../Sidebar' -import * as canvasStore from '@/stores/canvasStore' -import { TooltipProvider } from '@/components/ui/tooltip' - -vi.mock('@/stores/canvasStore') -vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) -vi.mock('@/api/client', () => ({ - scanApi: { - trigger: vi.fn(), - pending: vi.fn().mockResolvedValue({ data: [] }), - hidden: vi.fn().mockResolvedValue({ data: [] }), - runs: vi.fn().mockResolvedValue({ data: [] }), - getConfig: vi.fn().mockResolvedValue({ data: { ranges: [] } }), - }, - settingsApi: { - get: vi.fn(), - save: vi.fn(), - }, -})) - -import { settingsApi } from '@/api/client' -import { toast } from 'sonner' - -function renderSidebar() { - vi.mocked(canvasStore.useCanvasStore).mockReturnValue({ - nodes: [], - hasUnsavedChanges: false, - hideIp: false, - toggleHideIp: vi.fn(), - addNode: vi.fn(), - scanEventTs: 0, - } as unknown as ReturnType) - - return render( - - - - ) -} - -describe('SettingsPanel', () => { - beforeEach(() => { - vi.mocked(settingsApi.get).mockResolvedValue({ data: { interval_seconds: 60 } } as never) - vi.mocked(settingsApi.save).mockResolvedValue({ data: { interval_seconds: 60 } } as never) - vi.mocked(toast.success).mockReset() - vi.mocked(toast.error).mockReset() - }) - - it('opens when Settings item is clicked', async () => { - renderSidebar() - fireEvent.click(screen.getByRole('button', { name: 'Settings' })) - await waitFor(() => { - expect(settingsApi.get).toHaveBeenCalledOnce() - }) - expect(screen.getByText('Status check interval (s)')).toBeDefined() - }) - - it('displays interval loaded from API', async () => { - vi.mocked(settingsApi.get).mockResolvedValue({ data: { interval_seconds: 120 } } as never) - renderSidebar() - fireEvent.click(screen.getByRole('button', { name: 'Settings' })) - const input = await screen.findByDisplayValue('120') - expect(input).toBeDefined() - }) - - it('saves interval via settingsApi on Save click', async () => { - renderSidebar() - fireEvent.click(screen.getByRole('button', { name: 'Settings' })) - const input = await screen.findByDisplayValue('60') - fireEvent.change(input, { target: { value: '180' } }) - fireEvent.click(screen.getByRole('button', { name: 'Save' })) - await waitFor(() => { - expect(settingsApi.save).toHaveBeenCalledWith({ interval_seconds: 180 }) - expect(toast.success).toHaveBeenCalledWith('Settings saved') - }) - }) - - it('shows error toast when save fails', async () => { - vi.mocked(settingsApi.save).mockRejectedValue(new Error('network')) - renderSidebar() - fireEvent.click(screen.getByRole('button', { name: 'Settings' })) - await screen.findByDisplayValue('60') - fireEvent.click(screen.getByRole('button', { name: 'Save' })) - await waitFor(() => { - expect(toast.error).toHaveBeenCalledWith('Failed to save settings') - }) - }) - - it('closes panel when Settings is clicked again', async () => { - renderSidebar() - fireEvent.click(screen.getByRole('button', { name: 'Settings' })) - await screen.findByText('Status check interval (s)') - fireEvent.click(screen.getByRole('button', { name: 'Settings' })) - expect(screen.queryByText('Status check interval (s)')).toBeNull() - }) -}) diff --git a/frontend/src/components/panels/__tests__/Sidebar.test.tsx b/frontend/src/components/panels/__tests__/Sidebar.test.tsx index 91bacea..071aecb 100644 --- a/frontend/src/components/panels/__tests__/Sidebar.test.tsx +++ b/frontend/src/components/panels/__tests__/Sidebar.test.tsx @@ -73,6 +73,7 @@ const defaultProps = { onScan: vi.fn(), onZigbeeImport: vi.fn(), onSave: vi.fn(), + onOpenSettings: vi.fn(), onOpenPending: vi.fn(), } @@ -257,14 +258,10 @@ describe('Sidebar', () => { await waitFor(() => expect(screen.queryByText('No scans yet')).not.toBeInTheDocument()) }) - it('toggles Settings panel on Settings click', async () => { + it('calls onOpenSettings when Settings is clicked', () => { render() - fireEvent.click(screen.getByText('Settings')) - await waitFor(() => - expect(screen.getByText('Status check interval (s)')).toBeInTheDocument(), - ) fireEvent.click(screen.getByRole('button', { name: 'Settings' })) - expect(screen.queryByText('Status check interval (s)')).not.toBeInTheDocument() + expect(defaultProps.onOpenSettings).toHaveBeenCalledOnce() }) // ── Logout ─────────────────────────────────────────────────────────────────