diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 659d8070..8d66a56c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -40,7 +40,10 @@ const miniDrawerWidth = 60; const defaultRoute = '/channels'; const App = () => { - const [open, setOpen] = useState(true); + const [open, setOpen] = useState(() => { + const stored = localStorage.getItem('dispatcharr_sidebar_open'); + return stored === null ? true : stored === 'true'; + }); const isAuthenticated = useAuthStore((s) => s.isAuthenticated); const isInitialized = useAuthStore((s) => s.isInitialized); const setIsAuthenticated = useAuthStore((s) => s.setIsAuthenticated); @@ -53,7 +56,11 @@ const App = () => { const superuserCheckStarted = useRef(false); const toggleDrawer = () => { - setOpen(!open); + setOpen((prev) => { + const next = !prev; + localStorage.setItem('dispatcharr_sidebar_open', String(next)); + return next; + }); }; // Check if a superuser exists on first load. diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index 636db2e3..22cb7489 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -1,21 +1,23 @@ -import React, { useState, useMemo } from 'react'; -import { Link, useLocation } from 'react-router-dom'; +import React, { useState, useMemo, useEffect } from 'react'; +import { Link, useLocation, useNavigate } from 'react-router-dom'; import { copyToClipboard } from '../utils'; import { - Copy, - LogOut, - ChevronDown, + ArrowLeft, ChevronRight, + Copy, Heart, HelpCircle, + LogOut, } from 'lucide-react'; +import { SETTINGS_GROUPS } from '../config/settingsNav'; +import { SlidingPanels, usePanelNav } from './SlidingPanels'; import AboutModal from './AboutModal'; import { getOrderedNavItems } from '../config/navigation'; import { Avatar, + Box, Group, Stack, - Box, Text, UnstyledButton, ActionIcon, @@ -33,6 +35,8 @@ import { USER_LEVELS } from '../constants'; import UserForm from './forms/User'; import NotificationCenter from './NotificationCenter'; +// ─── Small shared components ───────────────────────────────────────────────── + const DonateButton = ({ tooltipPosition = 'top' }) => ( ( ); -const NavLink = ({ item, isActive, collapsed }) => { - const IconComponent = item.icon; +/** A single leaf nav item that navigates to item.path. */ +const NavItem = ({ item, isActive, collapsed }) => { + const Icon = item.icon; return ( - - {IconComponent && } - {!collapsed && ( - - {item.label} - - )} - {!collapsed && item.badge && ( - - {item.badge} - - )} - + + + {Icon && } + {!collapsed && ( + + {item.label} + + )} + {!collapsed && item.badge && ( + + {item.badge} + + )} + + ); }; -function NavGroup({ label, icon: IconComponent, paths, location, collapsed }) { - const [open, setOpen] = useState(() => - paths.some((p) => location.pathname.startsWith(p.path)) - ); - - const parentActive = paths - .map((path) => path.path) - .includes(location.pathname); - +/** Flat group with a decorative heading, matches the settings sub-panel design language. */ +function NavGroup({ label, paths, location, collapsed, onSettingsClick, settingsPanelOpen }) { return ( - - setOpen((o) => !o)} - className={`navlink ${parentActive ? 'navlink-parent-active' : ''} ${open ? 'navlink-collapsed' : ''}`} - style={{ width: '100%' }} - > - {IconComponent && } - {!collapsed && ( - - - {label} - - - - {open ? : } - - - )} - - - {open && ( - - - {paths.map((child) => { - const active = location.pathname === child.path; - return ( - - - - ); - })} - - + + {!collapsed && ( + + {label} + )} + + {paths.map((child) => { + if (child.path === '/settings' && onSettingsClick) { + const Icon = child.icon; + const isActive = settingsPanelOpen || location.pathname.startsWith('/settings'); + return ( + + + {Icon && } + {!collapsed && ( + <> + + {child.label} + + + + )} + + + ); + } + return ( + + ); + })} + ); } +/** Back button shown at the top of every sub-panel. */ +const BackButton = ({ label, collapsed, onClick }) => ( + + + + {!collapsed && ( + + {label} + + )} + + +); + +// ─── Sidebar ───────────────────────────────────────────────────────────────── + const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { const location = useLocation(); + const navigate = useNavigate(); const channelIds = useChannelsStore((s) => s.channelIds); const environment = useSettingsStore((s) => s.environment); @@ -165,20 +164,33 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { const [aboutOpen, setAboutOpen] = useState(false); const [ipRevealed, setIpRevealed] = useState(false); - const closeUserForm = () => setUserFormOpen(false); - const isAdmin = authUser && authUser.user_level >= USER_LEVELS.ADMIN; - // Navigation Items - computed from user's saved order, filtered by visibility const navOrder = getNavOrder(); const hiddenNav = getHiddenNav(); const navItems = useMemo(() => { - const orderedItems = getOrderedNavItems(navOrder, isAdmin, channelIds); - return orderedItems.filter((item) => !hiddenNav.includes(item.id)); + const ordered = getOrderedNavItems(navOrder, isAdmin, channelIds); + return ordered.filter((item) => !hiddenNav.includes(item.id)); }, [navOrder, hiddenNav, isAdmin, channelIds]); - // Environment settings and version are loaded by the settings store during initData() - // No need to fetch them again here - just use the store values + const isSettingsPage = location.pathname.startsWith('/settings'); + const activeSettingsId = location.hash.replace('#', ''); + const visibleSettingsGroups = SETTINGS_GROUPS.filter((g) => !g.adminOnly || isAdmin); + + // Panel navigation state, drives the SlidingPanels component + const nav = usePanelNav(); + // Destructure push so the stable setPanel reference appears in the dep array, + // not the recreated nav object. + const { push: pushPanel } = nav; + + // Sync settings route → panel state without causing loops. + useEffect(() => { + pushPanel((curr) => { + if (isSettingsPage && curr?.type !== 'settings') return { type: 'settings' }; + if (!isSettingsPage && curr?.type === 'settings') return null; + return curr; + }); + }, [isSettingsPage, pushPanel]); const copyPublicIP = async () => { await copyToClipboard(environment.public_ip, { @@ -187,23 +199,151 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { }); }; + const handleBack = () => { + nav.close(); // close panel only, content stays on whatever is currently rendered + }; + + // ── Panel content ──────────────────────────────────────────────────────── + + const primaryPanel = ( + + + {navItems.flatMap((item, idx) => { + const els = []; + + if (item.paths) { + if (idx > 0) { + els.push( + + ); + } + els.push( + nav.push({ type: 'settings' })} + settingsPanelOpen={nav.isOpen} + /> + ); + return els; + } + + // Settings leaf item: open sidebar sub-panel instead of navigating + if (item.path === '/settings') { + const Icon = item.icon; + const isActive = nav.isOpen || location.pathname.startsWith('/settings'); + els.push( + + nav.push({ type: 'settings' })} + className={`navlink${isActive ? ' navlink-active' : ''}${collapsed ? ' navlink-collapsed' : ''}`} + style={{ width: '100%' }} + > + {Icon && } + {!collapsed && ( + <> + + {item.label} + + + + )} + + + ); + return els; + } + + els.push( + + ); + return els; + })} + + + ); + + const secondaryPanel = ( + + + + {/* Settings sub-panel */} + {nav.displayed?.type === 'settings' && + visibleSettingsGroups.map((group, gi) => ( + + {gi > 0 && } + {!collapsed && ( + + {group.label} + + )} + + {group.sections.map((section) => { + const Icon = section.icon; + return ( + + navigate(`/settings#${section.id}`, { replace: true })} + className={`navlink${activeSettingsId === section.id ? ' navlink-active' : ''}${collapsed ? ' navlink-collapsed' : ''}`} + style={{ width: '100%' }} + > + + {!collapsed && ( + {section.label} + )} + + + ); + })} + + + ))} + + + + + + + + ); + + // ── Render ─────────────────────────────────────────────────────────────── + return ( - {/* Brand - Click to Toggle */} + {/* Brand: click to toggle collapse */} { whiteSpace: 'nowrap', }} > - {/* */} - + Dispatcharr {!collapsed && ( - + Dispatcharr )} - {/* Navigation Links */} - - - {navItems.map((item) => { - if (item.paths) { - return ( - - ); - } - - const isActive = location.pathname === item.path; - - return ( - - ); - })} - - + {/* Profile Section */} { environment.ip_lookup_enabled !== false && environment.ip_lookup_pending && ( - - Public IP - + Public IP )} @@ -305,13 +399,8 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { !environment.ip_lookup_pending && environment.public_ip && !environment.public_ip.startsWith('Error') && ( - setIpRevealed((v) => !v)} - style={{ cursor: 'pointer' }} - > - - Public IP - + setIpRevealed((v) => !v)} style={{ cursor: 'pointer' }}> + Public IP { {environment.country_code && ( { )} - + {(() => { const ip = environment.public_ip; const isIPv6 = ip.includes(':'); const sep = isIPv6 ? ':' : '.'; const parts = ip.split(sep); const splitAt = isIPv6 ? 4 : 2; - const visible = - parts.slice(0, splitAt).join(sep) + sep; - const hidden = parts.slice(splitAt).join(sep); return ( <> - {visible} - - {hidden} + {parts.slice(0, splitAt).join(sep) + sep} + + {parts.slice(splitAt).join(sep)} ); })()} - { - e.stopPropagation(); - copyPublicIP(); - }} - style={{ flexShrink: 0 }} - > + { e.stopPropagation(); copyPublicIP(); }} style={{ flexShrink: 0 }}> @@ -390,17 +449,14 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { )} {!collapsed && authUser && ( - + setUserFormOpen(true)}> {authUser.first_name || authUser.username} - + @@ -414,13 +470,9 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { )} - {/* Version and Notification */} + {/* Version and Notifications */} {!collapsed && ( - + { copyToClipboard( `v${appVersion?.version || '0.0.0'}${appVersion?.timestamp ? `-${appVersion.timestamp}` : ''}`, - { - successTitle: 'Copied', - successMessage: 'Version copied to clipboard', - } + { successTitle: 'Copied', successMessage: 'Version copied to clipboard' } ) } > - v{appVersion?.version || '0.0.0'} - {appVersion?.timestamp ? `-${appVersion.timestamp}` : ''} + v{appVersion?.version || '0.0.0'}{appVersion?.timestamp ? `-${appVersion.timestamp}` : ''} - setAboutOpen(true)} - > + setAboutOpen(true)}> @@ -466,30 +503,18 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { )} {collapsed && ( - + {isAuthenticated && } - setAboutOpen(true)} - > + setAboutOpen(true)}> )} - + setUserFormOpen(false)} /> setAboutOpen(false)} /> ); diff --git a/frontend/src/components/SlidingPanels.jsx b/frontend/src/components/SlidingPanels.jsx new file mode 100644 index 00000000..9296b727 --- /dev/null +++ b/frontend/src/components/SlidingPanels.jsx @@ -0,0 +1,66 @@ +import { useState, useRef } from 'react'; +import { Box } from '@mantine/core'; + +/** + * Manages drill-down panel navigation for a two-level sidebar. + * + * Usage: + * const nav = usePanelNav(); + * nav.push({ type: 'settings' }) // open secondary panel + * nav.close() // return to primary + * nav.displayed // safe to read during exit animation + */ +export function usePanelNav() { + const [panel, setPanel] = useState(null); + + // Retain last non-null value so secondary content stays visible + // during the slide-out exit animation (when panel is already null). + const lastRef = useRef(null); + if (panel !== null) lastRef.current = panel; + + return { + panel, // null = primary visible + displayed: panel ?? lastRef.current, + isOpen: panel !== null, + push: setPanel, // React's setPanel is stable, safe in deps + close: () => setPanel(null), + }; +} + +/** + * Animated two-panel container. + * + * When isOpen is false: primary is visible (translateX 0), secondary is off-screen right. + * When isOpen is true: secondary slides in from the right, primary slides out left. + * + * Props: + * isOpen {boolean} drives the transition + * primary {ReactNode} root panel content + * secondary {ReactNode} drill-down panel content + */ +export function SlidingPanels({ isOpen, primary, secondary }) { + return ( + + + {primary} + + + {secondary} + + + ); +} diff --git a/frontend/src/components/__tests__/Sidebar.test.jsx b/frontend/src/components/__tests__/Sidebar.test.jsx index a4e99f7f..a4f0ae2d 100644 --- a/frontend/src/components/__tests__/Sidebar.test.jsx +++ b/frontend/src/components/__tests__/Sidebar.test.jsx @@ -26,45 +26,50 @@ vi.mock('../AboutModal', () => ({ default: () => null, })); -// Mock lucide-react icons -vi.mock('lucide-react', () => ({ - ListOrdered: ({ onClick }) => ( -
- ), - Play: ({ onClick }) =>
, - Database: ({ onClick }) => ( -
- ), - LayoutGrid: ({ onClick }) => ( -
- ), - Settings: ({ onClick }) => ( -
- ), - Copy: ({ onClick }) =>
, - ChartLine: ({ onClick }) => ( -
- ), - Video: ({ onClick }) =>
, - PlugZap: ({ onClick }) => ( -
- ), - LogOut: ({ onClick }) =>
, - User: ({ onClick }) =>
, - FileImage: ({ onClick }) => ( -
- ), - Webhook: () =>
, - Logs: () =>
, - ChevronDown: () =>
, - ChevronRight: () =>
, - MonitorCog: () =>
, - Blocks: () =>
, - Heart: () =>
, - Package: () =>
, - Download: () =>
, - HelpCircle: () =>
, -})); +// Mock lucide-react icons: spread actual module so settingsNav icons are available, +// override specific ones with testid-bearing stubs for assertions. +vi.mock('lucide-react', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ListOrdered: ({ onClick }) => ( +
+ ), + Play: ({ onClick }) =>
, + Database: ({ onClick }) => ( +
+ ), + LayoutGrid: ({ onClick }) => ( +
+ ), + Settings: ({ onClick }) => ( +
+ ), + Copy: ({ onClick }) =>
, + ChartLine: ({ onClick }) => ( +
+ ), + Video: ({ onClick }) =>
, + PlugZap: ({ onClick }) => ( +
+ ), + LogOut: ({ onClick }) =>
, + User: ({ onClick }) =>
, + FileImage: ({ onClick }) => ( +
+ ), + Webhook: () =>
, + Logs: () =>
, + ChevronRight: () =>
, + MonitorCog: () =>
, + Blocks: () =>
, + Heart: () =>
, + Package: () =>
, + Download: () =>
, + HelpCircle: () =>
, + ArrowLeft: () =>
, + }; +}); // Mock UserForm component vi.mock('../forms/User', () => ({ @@ -509,98 +514,35 @@ describe('Sidebar', () => { }); describe('NavGroup Component', () => { - it('should render Integrations group with children collapsed by default', () => { + it('renders Integrations group heading and children always visible', () => { renderSidebar(); expect(screen.getByText('Integrations')).toBeInTheDocument(); - expect(screen.queryByText('Connections')).not.toBeInTheDocument(); - expect(screen.queryByText('Logs')).not.toBeInTheDocument(); + expect(screen.getByText('Connections')).toBeInTheDocument(); + expect(screen.getByText('Logs')).toBeInTheDocument(); }); - it('should expand Integrations group when clicked', async () => { - renderSidebar(); - - const integrationsGroup = screen - .getByText('Integrations') - .closest('button'); - fireEvent.click(integrationsGroup); - - await waitFor(() => { - expect(screen.getByText('Connections')).toBeInTheDocument(); - expect(screen.getByText('Logs')).toBeInTheDocument(); - }); - }); - - it('should collapse Integrations group when clicked again', async () => { - renderSidebar(); - - const integrationsGroup = screen - .getByText('Integrations') - .closest('button'); - - // Expand - fireEvent.click(integrationsGroup); - await waitFor(() => { - expect(screen.getByText('Connections')).toBeInTheDocument(); - }); - - // Collapse - fireEvent.click(integrationsGroup); - await waitFor(() => { - expect(screen.queryByText('Connections')).not.toBeInTheDocument(); - expect(screen.queryByText('Logs')).not.toBeInTheDocument(); - }); - }); - - it('should render System group with children collapsed by default', () => { + it('renders System group heading and children always visible', () => { renderSidebar(); expect(screen.getByText('System')).toBeInTheDocument(); - expect(screen.queryByText('Users')).not.toBeInTheDocument(); - expect(screen.queryByText('Logo Manager')).not.toBeInTheDocument(); + expect(screen.getByText('Users')).toBeInTheDocument(); + expect(screen.getByText('Logo Manager')).toBeInTheDocument(); }); - it('should expand System group when clicked', async () => { + it('renders both groups simultaneously', () => { renderSidebar(); - const systemGroup = screen.getByText('System').closest('button'); - fireEvent.click(systemGroup); - - await waitFor(() => { - expect(screen.getByText('Users')).toBeInTheDocument(); - expect(screen.getByText('Logo Manager')).toBeInTheDocument(); - expect(screen.getByText('Settings')).toBeInTheDocument(); - }); + expect(screen.getByText('Connections')).toBeInTheDocument(); + expect(screen.getByText('Users')).toBeInTheDocument(); }); - it('should hide group label when collapsed sidebar', () => { + it('hides group headings when sidebar is collapsed', () => { renderSidebar({ collapsed: true }); expect(screen.queryByText('Integrations')).not.toBeInTheDocument(); expect(screen.queryByText('System')).not.toBeInTheDocument(); }); - - it('should not show multiple groups collapsed when both expanded', async () => { - renderSidebar(); - - const integrationsGroup = screen - .getByText('Integrations') - .closest('button'); - const systemGroup = screen.getByText('System').closest('button'); - - // Expand Integrations - fireEvent.click(integrationsGroup); - await waitFor(() => { - expect(screen.getByText('Connections')).toBeInTheDocument(); - }); - - // Expand System (Integrations should remain expanded) - fireEvent.click(systemGroup); - await waitFor(() => { - expect(screen.getByText('Users')).toBeInTheDocument(); - expect(screen.getByText('Connections')).toBeInTheDocument(); - }); - }); }); describe('NotificationCenter Integration', () => { diff --git a/frontend/src/components/sidebar.css b/frontend/src/components/sidebar.css index ccbf88e9..356913cf 100644 --- a/frontend/src/components/sidebar.css +++ b/frontend/src/components/sidebar.css @@ -4,11 +4,11 @@ flex-wrap: nowrap; align-items: center; gap: 12px; - padding: 5px 8px !important; + padding: 4px 8px !important; border-radius: 6px; background-color: transparent; /* Default background when not active */ border: 1px solid transparent; - transition: all 0.3s ease; + transition: background-color 0.3s ease, border-color 0.3s ease, color 0.3s ease; margin-left: 2px; } @@ -20,8 +20,8 @@ } /* Hover effect */ -.navlink:hover, .navlink-parent-active { - background-color: #2A2F34; /* Gray hover effect when not active */ +.navlink:hover { + background-color: #2A2F34; border: 1px solid #3D3D42; } @@ -34,25 +34,11 @@ /* Collapse condition for justifyContent */ .navlink.navlink-collapsed { justify-content: center; + padding: 4px !important; } .navlink:not(.navlink-collapsed) { justify-content: flex-start; } -/* Left indicator for open nav groups rendered outside buttons */ -.navgroup-open { - position: relative; - overflow: visible; -} -.navgroup-open::before { - content: ''; - position: absolute; - left: 0; /* avoid horizontal overflow; sits at container edge */ - top: 0; - bottom: 0; - width: 2px; - background: #3BA882; - pointer-events: none; - z-index: 2; -} + diff --git a/frontend/src/config/settingsNav.js b/frontend/src/config/settingsNav.js new file mode 100644 index 00000000..4e7c9311 --- /dev/null +++ b/frontend/src/config/settingsNav.js @@ -0,0 +1,80 @@ +import { + ArrowLeftRight, + CalendarDays, + DatabaseBackup, + FileOutput, + Menu, + Monitor, + Network, + Palette, + Settings2, + SlidersHorizontal, + Tv, + Users, + Video, +} from 'lucide-react'; + +export const SETTINGS_GROUPS = [ + { + id: 'interface', + label: 'Interface', + adminOnly: false, + sections: [ + { id: 'ui-settings', label: 'UI Settings', icon: Palette }, + { id: 'nav-order', label: 'Navigation', icon: Menu }, + ], + }, + { + id: 'streaming', + label: 'Streaming', + adminOnly: true, + sections: [ + { id: 'stream-settings', label: 'Stream Settings', icon: Video }, + { id: 'proxy-settings', label: 'Proxy Settings', icon: ArrowLeftRight }, + { id: 'stream-profiles', label: 'Stream Profiles', icon: SlidersHorizontal }, + { id: 'output-profiles', label: 'Output Profiles', icon: FileOutput }, + ], + }, + { + id: 'dvr', + label: 'DVR', + adminOnly: true, + sections: [ + { id: 'dvr-settings', label: 'DVR Settings', icon: Tv }, + ], + }, + { + id: 'epg', + label: 'EPG', + adminOnly: true, + sections: [ + { id: 'epg-settings', label: 'EPG', icon: CalendarDays }, + ], + }, + { + id: 'network', + label: 'Network', + adminOnly: true, + sections: [ + { id: 'user-agents', label: 'User-Agents', icon: Monitor }, + { id: 'network-access', label: 'Network Access', icon: Network }, + ], + }, + { + id: 'system', + label: 'System', + adminOnly: true, + sections: [ + { id: 'system-settings', label: 'System Settings', icon: Settings2 }, + { id: 'user-limits', label: 'User Limits', icon: Users }, + ], + }, + { + id: 'backup', + label: 'Backup', + adminOnly: true, + sections: [ + { id: 'backups', label: 'Backup & Restore', icon: DatabaseBackup }, + ], + }, +]; diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 1c7964cc..6af7013d 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -1,16 +1,18 @@ import React, { Suspense, useState, useEffect } from 'react'; import { useLocation } from 'react-router-dom'; import { - Accordion, - AccordionControl, - AccordionItem, - AccordionPanel, Box, - Center, Divider, - Text, Loader, + Paper, + Text, } from '@mantine/core'; +import { SETTINGS_GROUPS } from '../config/settingsNav'; +import useAuthStore from '../store/auth'; +import { USER_LEVELS } from '../constants'; +import UiSettingsForm from '../components/forms/settings/UiSettingsForm.jsx'; +import ErrorBoundary from '../components/ErrorBoundary.jsx'; + const UserAgentsTable = React.lazy( () => import('../components/tables/UserAgentsTable.jsx') ); @@ -23,10 +25,6 @@ const OutputProfilesTable = React.lazy( const BackupManager = React.lazy( () => import('../components/backups/BackupManager.jsx') ); -import useAuthStore from '../store/auth'; -import { USER_LEVELS } from '../constants'; -import UiSettingsForm from '../components/forms/settings/UiSettingsForm.jsx'; -import ErrorBoundary from '../components/ErrorBoundary.jsx'; const UserLimitsForm = React.lazy( () => import('../components/forms/settings/UserLimitsForm.jsx') ); @@ -52,207 +50,73 @@ const NavOrderForm = React.lazy( () => import('../components/forms/settings/NavOrderForm.jsx') ); +const COMPONENT_MAP = { + 'ui-settings': UiSettingsForm, + 'nav-order': NavOrderForm, + 'stream-settings': StreamSettingsForm, + 'stream-profiles': StreamProfilesTable, + 'output-profiles': OutputProfilesTable, + 'dvr-settings': DvrSettingsForm, + 'epg-settings': EpgSettingsForm, + 'user-agents': UserAgentsTable, + 'network-access': NetworkAccessForm, + 'proxy-settings': ProxySettingsForm, + 'system-settings': SystemSettingsForm, + 'user-limits': UserLimitsForm, + backups: BackupManager, +}; + const SettingsPage = () => { const authUser = useAuthStore((s) => s.user); const location = useLocation(); + const isAdmin = authUser.user_level >= USER_LEVELS.ADMIN; - const [accordianValue, setAccordianValue] = useState('ui-settings'); + const [activeSection, setActiveSection] = useState( + () => location.hash.replace('#', '') || null + ); - // Handle hash navigation to open specific accordion useEffect(() => { const hash = location.hash.replace('#', ''); - if (hash) { - setAccordianValue(hash); - } + if (hash) setActiveSection(hash); }, [location.hash]); + const visibleGroups = SETTINGS_GROUPS.filter((g) => !g.adminOnly || isAdmin); + const allSections = visibleGroups.flatMap((g) => g.sections); + const activeSectionConfig = activeSection + ? (allSections.find((s) => s.id === activeSection) ?? null) + : null; + const ActiveComponent = activeSectionConfig ? COMPONENT_MAP[activeSectionConfig.id] : null; + return ( -
- - + {ActiveComponent ? ( + + + {activeSectionConfig.label} + + + + }> + + + + + ) : ( + - - UI Settings - - - - - - Navigation - - - }> - - - - - - - - - - {authUser.user_level >= USER_LEVELS.ADMIN && ( - <> - - DVR - - - }> - - - - - - - - Stream Settings - - - }> - - - - - - - - EPG - - - }> - - - - - - - - System Settings - - - }> - - - - - - - - User-Agents - - - }> - - - - - - - - Stream Profiles - - - }> - - - - - - - - Output Profiles - - - }> - - - - - - - - - Network Access - {accordianValue === 'network-access' && ( - - Comma-Delimited CIDR ranges - - )} - - - - }> - - - - - - - - - Proxy Settings - - - - }> - - - - - - - - Backup & Restore - - - }> - - - - - - - - User Limits - - - }> - - - - - - - )} - - -
+ + Select a setting from the sidebar + + + )} + ); }; diff --git a/frontend/src/pages/__tests__/Settings.test.jsx b/frontend/src/pages/__tests__/Settings.test.jsx index 7a01b95a..27c38347 100644 --- a/frontend/src/pages/__tests__/Settings.test.jsx +++ b/frontend/src/pages/__tests__/Settings.test.jsx @@ -4,298 +4,181 @@ import { MemoryRouter } from 'react-router-dom'; import SettingsPage from '../Settings'; import useAuthStore from '../../store/auth'; import { USER_LEVELS } from '../../constants'; -import userEvent from '@testing-library/user-event'; -// Mock all dependencies vi.mock('../../store/auth'); vi.mock('../../components/tables/UserAgentsTable', () => ({ default: ({ active }) => ( -
- UserAgentsTable {active ? 'active' : 'inactive'} -
+
UserAgentsTable {active ? 'active' : 'inactive'}
), })); vi.mock('../../components/tables/StreamProfilesTable', () => ({ default: ({ active }) => ( -
- StreamProfilesTable {active ? 'active' : 'inactive'} -
+
StreamProfilesTable {active ? 'active' : 'inactive'}
), })); vi.mock('../../components/backups/BackupManager', () => ({ default: ({ active }) => ( -
- BackupManager {active ? 'active' : 'inactive'} -
+
BackupManager {active ? 'active' : 'inactive'}
), })); vi.mock('../../components/forms/settings/UiSettingsForm', () => ({ default: ({ active }) => ( -
- UiSettingsForm {active ? 'active' : 'inactive'} -
+
UiSettingsForm {active ? 'active' : 'inactive'}
), })); vi.mock('../../components/forms/settings/NetworkAccessForm', () => ({ default: ({ active }) => ( -
- NetworkAccessForm {active ? 'active' : 'inactive'} -
+
NetworkAccessForm {active ? 'active' : 'inactive'}
), })); vi.mock('../../components/forms/settings/ProxySettingsForm', () => ({ default: ({ active }) => ( -
- ProxySettingsForm {active ? 'active' : 'inactive'} -
+
ProxySettingsForm {active ? 'active' : 'inactive'}
), })); vi.mock('../../components/forms/settings/StreamSettingsForm', () => ({ default: ({ active }) => ( -
- StreamSettingsForm {active ? 'active' : 'inactive'} -
+
StreamSettingsForm {active ? 'active' : 'inactive'}
), })); vi.mock('../../components/forms/settings/DvrSettingsForm', () => ({ default: ({ active }) => ( -
- DvrSettingsForm {active ? 'active' : 'inactive'} -
+
DvrSettingsForm {active ? 'active' : 'inactive'}
), })); vi.mock('../../components/forms/settings/SystemSettingsForm', () => ({ default: ({ active }) => ( -
- SystemSettingsForm {active ? 'active' : 'inactive'} -
+
SystemSettingsForm {active ? 'active' : 'inactive'}
), })); vi.mock('../../components/forms/settings/NavOrderForm', () => ({ default: ({ active }) => ( -
- NavOrderForm {active ? 'active' : 'inactive'} -
+
NavOrderForm {active ? 'active' : 'inactive'}
), })); vi.mock('../../components/forms/settings/UserLimitsForm', () => ({ default: ({ active }) => ( -
- UserLimitsForm {active ? 'active' : 'inactive'} -
+
UserLimitsForm {active ? 'active' : 'inactive'}
), })); vi.mock('../../components/ErrorBoundary', () => ({ default: ({ children }) =>
{children}
, })); -vi.mock('@mantine/core', async () => { - const accordionComponent = ({ children, onChange, defaultValue }) => ( -
{children}
- ); - accordionComponent.Item = ({ children, value }) => ( -
{children}
- ); - accordionComponent.Control = ({ children }) => ( - - ); - accordionComponent.Panel = ({ children }) => ( -
{children}
- ); +vi.mock('@mantine/core', async () => ({ + Box: ({ children }) =>
{children}
, + Divider: () =>
, + Loader: () =>
Loading...
, + Paper: ({ children }) =>
{children}
, + Text: ({ children }) => {children}, +})); - return { - Accordion: accordionComponent, - AccordionItem: accordionComponent.Item, - AccordionControl: accordionComponent.Control, - AccordionPanel: accordionComponent.Panel, - Box: ({ children }) =>
{children}
, - Center: ({ children }) =>
{children}
, - Divider: () =>
, - Loader: () =>
Loading...
, - Text: ({ children }) => {children}, - }; -}); - -// Helper function to render with router context -const renderWithRouter = ( - component, - { initialEntries = ['/settings'] } = {} -) => { - return render( - {component} - ); -}; +const renderWithRouter = (component, { initialEntries = ['/settings'] } = {}) => + render({component}); describe('SettingsPage', () => { beforeEach(() => { vi.clearAllMocks(); }); - describe('Rendering for Regular User', () => { + describe('no section selected', () => { beforeEach(() => { - useAuthStore.mockReturnValue({ - user_level: USER_LEVELS.USER, - username: 'testuser', - }); + useAuthStore.mockReturnValue({ user_level: USER_LEVELS.USER, username: 'testuser' }); }); - it('renders the settings page', () => { + it('shows placeholder when no hash', () => { renderWithRouter(); - - expect(screen.getAllByTestId('accordion').length).toBeGreaterThan(0); - }); - - it('renders UI Settings accordion item', () => { - renderWithRouter(); - - expect( - screen.getByTestId('accordion-item-ui-settings') - ).toBeInTheDocument(); - expect(screen.getByText('UI Settings')).toBeInTheDocument(); - }); - - it('opens UI Settings panel by default', () => { - renderWithRouter(); - - expect(screen.getByTestId('ui-settings-form')).toBeInTheDocument(); - }); - - it('does not render admin-only sections for regular users', () => { - renderWithRouter(); - - expect(screen.queryByText('DVR')).not.toBeInTheDocument(); - expect(screen.queryByText('Stream Settings')).not.toBeInTheDocument(); - expect(screen.queryByText('System Settings')).not.toBeInTheDocument(); - expect(screen.queryByText('User-Agents')).not.toBeInTheDocument(); - expect(screen.queryByText('Stream Profiles')).not.toBeInTheDocument(); - expect(screen.queryByText('Network Access')).not.toBeInTheDocument(); - expect(screen.queryByText('Proxy Settings')).not.toBeInTheDocument(); - expect(screen.queryByText('Backup & Restore')).not.toBeInTheDocument(); - }); - - it('renders Navigation accordion item for regular users', () => { - renderWithRouter(); - - expect( - screen.getByTestId('accordion-item-nav-order') - ).toBeInTheDocument(); - expect(screen.getByText('Navigation')).toBeInTheDocument(); + expect(screen.getByText('Select a setting from the sidebar')).toBeInTheDocument(); }); }); - describe('Rendering for Admin User', () => { + describe('regular user', () => { beforeEach(() => { - useAuthStore.mockReturnValue({ - user_level: USER_LEVELS.ADMIN, - username: 'admin', - }); + useAuthStore.mockReturnValue({ user_level: USER_LEVELS.USER, username: 'testuser' }); }); - it('renders all accordion items for admin', async () => { - renderWithRouter(); - - expect(screen.getByText('UI Settings')).toBeInTheDocument(); - + it('renders ui-settings section via hash', async () => { + renderWithRouter(, { initialEntries: ['/settings#ui-settings'] }); await waitFor(() => { - expect(screen.getByText('DVR')).toBeInTheDocument(); - expect(screen.getByText('Stream Settings')).toBeInTheDocument(); - expect(screen.getByText('System Settings')).toBeInTheDocument(); - expect(screen.getByText('User-Agents')).toBeInTheDocument(); - expect(screen.getByText('Stream Profiles')).toBeInTheDocument(); - expect(screen.getByText('Network Access')).toBeInTheDocument(); - expect(screen.getByText('Proxy Settings')).toBeInTheDocument(); - expect(screen.getByText('Backup & Restore')).toBeInTheDocument(); - expect(screen.getByText('Navigation')).toBeInTheDocument(); + expect(screen.getByTestId('ui-settings-form')).toBeInTheDocument(); + }); + expect(screen.getByText('UI Settings')).toBeInTheDocument(); + }); + + it('renders nav-order section via hash', async () => { + renderWithRouter(, { initialEntries: ['/settings#nav-order'] }); + await waitFor(() => { + expect(screen.getByTestId('nav-order-form')).toBeInTheDocument(); }); }); - it('renders DVR settings accordion item', () => { - renderWithRouter(); - - expect( - screen.getByTestId('accordion-item-dvr-settings') - ).toBeInTheDocument(); - }); - - it('renders Stream Settings accordion item', () => { - renderWithRouter(); - - expect( - screen.getByTestId('accordion-item-stream-settings') - ).toBeInTheDocument(); - }); - - it('renders System Settings accordion item', () => { - renderWithRouter(); - - expect( - screen.getByTestId('accordion-item-system-settings') - ).toBeInTheDocument(); - }); - - it('renders User-Agents accordion item', () => { - renderWithRouter(); - - expect( - screen.getByTestId('accordion-item-user-agents') - ).toBeInTheDocument(); - }); - - it('renders Stream Profiles accordion item', () => { - renderWithRouter(); - - expect( - screen.getByTestId('accordion-item-stream-profiles') - ).toBeInTheDocument(); - }); - - it('renders Network Access accordion item', () => { - renderWithRouter(); - - expect( - screen.getByTestId('accordion-item-network-access') - ).toBeInTheDocument(); - }); - - it('renders Proxy Settings accordion item', () => { - renderWithRouter(); - - expect( - screen.getByTestId('accordion-item-proxy-settings') - ).toBeInTheDocument(); - }); - - it('renders Backup & Restore accordion item', () => { - renderWithRouter(); - - expect(screen.getByTestId('accordion-item-backups')).toBeInTheDocument(); - }); - - it('renders Navigation accordion item', () => { - renderWithRouter(); - - expect( - screen.getByTestId('accordion-item-nav-order') - ).toBeInTheDocument(); + it('shows placeholder for admin-only section hash', () => { + renderWithRouter(, { initialEntries: ['/settings#dvr-settings'] }); + expect(screen.getByText('Select a setting from the sidebar')).toBeInTheDocument(); }); }); - describe('Accordion Interactions', () => { + describe('admin user', () => { beforeEach(() => { - useAuthStore.mockReturnValue({ - user_level: USER_LEVELS.ADMIN, - username: 'admin', + useAuthStore.mockReturnValue({ user_level: USER_LEVELS.ADMIN, username: 'admin' }); + }); + + it('renders dvr-settings section via hash', async () => { + renderWithRouter(, { initialEntries: ['/settings#dvr-settings'] }); + await waitFor(() => { + expect(screen.getByTestId('dvr-settings-form')).toBeInTheDocument(); + }); + expect(screen.getByText('DVR Settings')).toBeInTheDocument(); + }); + + it('renders stream-settings section via hash', async () => { + renderWithRouter(, { initialEntries: ['/settings#stream-settings'] }); + await waitFor(() => { + expect(screen.getByTestId('stream-settings-form')).toBeInTheDocument(); }); }); - it('opens DVR settings when clicked', async () => { - const user = userEvent.setup(); - renderWithRouter(); + it('renders stream-profiles section via hash', async () => { + renderWithRouter(, { initialEntries: ['/settings#stream-profiles'] }); + await waitFor(() => { + expect(screen.getByTestId('stream-profiles-table')).toBeInTheDocument(); + }); + }); - const streamSettingsButton = screen.getByText('DVR'); - await user.click(streamSettingsButton); + it('renders network-access section via hash', async () => { + renderWithRouter(, { initialEntries: ['/settings#network-access'] }); + await waitFor(() => { + expect(screen.getByTestId('network-access-form')).toBeInTheDocument(); + }); + }); - await screen.findByTestId('dvr-settings-form'); + it('renders proxy-settings section via hash', async () => { + renderWithRouter(, { initialEntries: ['/settings#proxy-settings'] }); + await waitFor(() => { + expect(screen.getByTestId('proxy-settings-form')).toBeInTheDocument(); + }); + }); + + it('renders backups section via hash', async () => { + renderWithRouter(, { initialEntries: ['/settings#backups'] }); + await waitFor(() => { + expect(screen.getByTestId('backup-manager')).toBeInTheDocument(); + }); + }); + + it('renders system-settings section via hash', async () => { + renderWithRouter(, { initialEntries: ['/settings#system-settings'] }); + await waitFor(() => { + expect(screen.getByTestId('system-settings-form')).toBeInTheDocument(); + }); + }); + + it('passes active=true to rendered component', async () => { + renderWithRouter(, { initialEntries: ['/settings#dvr-settings'] }); + await waitFor(() => { + expect(screen.getByText(/active/)).toBeInTheDocument(); + }); + expect(screen.getByText('DvrSettingsForm active')).toBeInTheDocument(); }); }); });