From e2205fb454e0d86fa78d55fbc9470cae3b36daf8 Mon Sep 17 00:00:00 2001 From: Jeff Casimir Date: Sat, 31 Jan 2026 13:45:13 -0700 Subject: [PATCH] Add NavOrderForm component for drag-and-drop nav ordering - Drag-and-drop reorderable list using dnd-kit - Auto-saves on drop with optimistic update - Reset to Default button restores role-based defaults - Shows only items available to user's role - Add test coverage for admin and non-admin users Co-Authored-By: Claude Opus 4.5 --- .../forms/settings/NavOrderForm.jsx | 213 ++++++++++++++++++ .../settings/__tests__/NavOrderForm.test.jsx | 190 ++++++++++++++++ 2 files changed, 403 insertions(+) create mode 100644 frontend/src/components/forms/settings/NavOrderForm.jsx create mode 100644 frontend/src/components/forms/settings/__tests__/NavOrderForm.test.jsx diff --git a/frontend/src/components/forms/settings/NavOrderForm.jsx b/frontend/src/components/forms/settings/NavOrderForm.jsx new file mode 100644 index 00000000..9bd56fe2 --- /dev/null +++ b/frontend/src/components/forms/settings/NavOrderForm.jsx @@ -0,0 +1,213 @@ +import React, { useState, useEffect } from 'react'; +import { + Box, + Button, + Text, + Group, + ActionIcon, + Stack, + useMantineTheme, +} from '@mantine/core'; +import { notifications } from '@mantine/notifications'; +import { GripVertical } from 'lucide-react'; +import { + closestCenter, + DndContext, + KeyboardSensor, + MouseSensor, + TouchSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { + arrayMove, + SortableContext, + useSortable, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { restrictToVerticalAxis } from '@dnd-kit/modifiers'; +import useAuthStore from '../../../store/auth'; +import { + NAV_ITEMS, + DEFAULT_ADMIN_ORDER, + DEFAULT_USER_ORDER, + getOrderedNavItems, +} from '../../../config/navigation'; +import { USER_LEVELS } from '../../../constants'; + +const DraggableNavItem = ({ item }) => { + const theme = useMantineTheme(); + const { transform, transition, setNodeRef, isDragging, attributes, listeners } = useSortable({ + id: item.id, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition: transition, + opacity: isDragging ? 0.8 : 1, + zIndex: isDragging ? 1 : 0, + position: 'relative', + }; + + const IconComponent = item.icon; + + return ( + + + + + + + {IconComponent && } + + {item.label} + + + + + ); +}; + +const NavOrderForm = ({ active }) => { + const theme = useMantineTheme(); + const user = useAuthStore((s) => s.user); + const getNavOrder = useAuthStore((s) => s.getNavOrder); + const setNavOrder = useAuthStore((s) => s.setNavOrder); + + const isAdmin = user?.user_level >= USER_LEVELS.ADMIN; + const defaultOrder = isAdmin ? DEFAULT_ADMIN_ORDER : DEFAULT_USER_ORDER; + + const [items, setItems] = useState([]); + const [isSaving, setIsSaving] = useState(false); + + const sensors = useSensors( + useSensor(MouseSensor, {}), + useSensor(TouchSensor, {}), + useSensor(KeyboardSensor, {}) + ); + + useEffect(() => { + if (active) { + const savedOrder = getNavOrder(); + const orderedItems = getOrderedNavItems(savedOrder, isAdmin); + setItems(orderedItems); + } + }, [active, isAdmin, getNavOrder]); + + const handleDragEnd = async ({ active, over }) => { + if (!over || active.id === over.id) return; + + const oldIndex = items.findIndex((item) => item.id === active.id); + const newIndex = items.findIndex((item) => item.id === over.id); + const newItems = arrayMove(items, oldIndex, newIndex); + + // Optimistic update + setItems(newItems); + + // Save to backend + setIsSaving(true); + try { + const newOrder = newItems.map((item) => item.id); + await setNavOrder(newOrder); + notifications.show({ + title: 'Navigation', + message: 'Order saved successfully', + color: 'green', + autoClose: 2000, + }); + } catch (error) { + // Revert on failure + const savedOrder = getNavOrder(); + const orderedItems = getOrderedNavItems(savedOrder, isAdmin); + setItems(orderedItems); + notifications.show({ + title: 'Error', + message: 'Failed to save navigation order', + color: 'red', + }); + } finally { + setIsSaving(false); + } + }; + + const handleReset = async () => { + setIsSaving(true); + try { + await setNavOrder(defaultOrder); + const orderedItems = getOrderedNavItems(defaultOrder, isAdmin); + setItems(orderedItems); + notifications.show({ + title: 'Navigation', + message: 'Reset to default order', + color: 'blue', + autoClose: 2000, + }); + } catch (error) { + notifications.show({ + title: 'Error', + message: 'Failed to reset navigation order', + color: 'red', + }); + } finally { + setIsSaving(false); + } + }; + + if (!active) { + return null; + } + + return ( + + + Drag and drop to reorder the sidebar navigation items. + + + + item.id)} + strategy={verticalListSortingStrategy} + > + {items.map((item) => ( + + ))} + + + + + + + + ); +}; + +export default NavOrderForm; diff --git a/frontend/src/components/forms/settings/__tests__/NavOrderForm.test.jsx b/frontend/src/components/forms/settings/__tests__/NavOrderForm.test.jsx new file mode 100644 index 00000000..3d60e11c --- /dev/null +++ b/frontend/src/components/forms/settings/__tests__/NavOrderForm.test.jsx @@ -0,0 +1,190 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import NavOrderForm from '../NavOrderForm'; +import useAuthStore from '../../../../store/auth'; +import { USER_LEVELS } from '../../../../constants'; +import { DEFAULT_ADMIN_ORDER, DEFAULT_USER_ORDER } from '../../../../config/navigation'; + +// Mock dependencies +vi.mock('../../../../store/auth'); +vi.mock('@mantine/notifications', () => ({ + notifications: { + show: vi.fn(), + }, +})); + +// Mock dnd-kit +vi.mock('@dnd-kit/core', () => ({ + DndContext: ({ children }) =>
{children}
, + closestCenter: vi.fn(), + KeyboardSensor: vi.fn(), + MouseSensor: vi.fn(), + TouchSensor: vi.fn(), + useSensor: vi.fn(), + useSensors: vi.fn(() => []), +})); + +vi.mock('@dnd-kit/sortable', () => ({ + SortableContext: ({ children }) =>
{children}
, + useSortable: () => ({ + transform: null, + transition: null, + setNodeRef: vi.fn(), + isDragging: false, + attributes: {}, + listeners: {}, + }), + arrayMove: vi.fn((arr, from, to) => { + const result = [...arr]; + const [removed] = result.splice(from, 1); + result.splice(to, 0, removed); + return result; + }), + verticalListSortingStrategy: vi.fn(), +})); + +vi.mock('@dnd-kit/utilities', () => ({ + CSS: { + Transform: { + toString: vi.fn(() => ''), + }, + }, +})); + +vi.mock('@dnd-kit/modifiers', () => ({ + restrictToVerticalAxis: vi.fn(), +})); + +// Mock Mantine components +vi.mock('@mantine/core', () => ({ + Box: ({ children, ...props }) =>
{children}
, + Button: ({ children, onClick, disabled, ...props }) => ( + + ), + Text: ({ children }) => {children}, + Group: ({ children }) =>
{children}
, + ActionIcon: ({ children, ...props }) => , + Stack: ({ children }) =>
{children}
, + useMantineTheme: () => ({}), +})); + +describe('NavOrderForm', () => { + const mockSetNavOrder = vi.fn(); + const mockGetNavOrder = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + mockSetNavOrder.mockResolvedValue({}); + mockGetNavOrder.mockReturnValue(null); + }); + + describe('Admin User', () => { + beforeEach(() => { + useAuthStore.mockImplementation((selector) => { + const state = { + user: { user_level: USER_LEVELS.ADMIN, custom_properties: {} }, + getNavOrder: mockGetNavOrder, + setNavOrder: mockSetNavOrder, + }; + return selector(state); + }); + }); + + it('renders all nav items for admin user', () => { + render(); + + expect(screen.getByText('Channels')).toBeInTheDocument(); + expect(screen.getByText('VODs')).toBeInTheDocument(); + expect(screen.getByText('M3U & EPG Manager')).toBeInTheDocument(); + expect(screen.getByText('TV Guide')).toBeInTheDocument(); + expect(screen.getByText('DVR')).toBeInTheDocument(); + expect(screen.getByText('Stats')).toBeInTheDocument(); + expect(screen.getByText('Plugins')).toBeInTheDocument(); + expect(screen.getByText('Users')).toBeInTheDocument(); + expect(screen.getByText('Logo Manager')).toBeInTheDocument(); + expect(screen.getByText('Settings')).toBeInTheDocument(); + }); + + it('renders reset to default button', () => { + render(); + + expect(screen.getByTestId('reset-button')).toBeInTheDocument(); + expect(screen.getByText('Reset to Default')).toBeInTheDocument(); + }); + + it('does not render when not active', () => { + render(); + + expect(screen.queryByText('Channels')).not.toBeInTheDocument(); + }); + + it('calls setNavOrder when reset button is clicked', async () => { + const user = userEvent.setup(); + render(); + + const resetButton = screen.getByTestId('reset-button'); + await user.click(resetButton); + + await waitFor(() => { + expect(mockSetNavOrder).toHaveBeenCalledWith(DEFAULT_ADMIN_ORDER); + }); + }); + + it('uses saved order when available', () => { + const customOrder = ['settings', 'channels', 'vods', 'sources', 'guide', 'dvr', 'stats', 'plugins', 'users', 'logos']; + mockGetNavOrder.mockReturnValue(customOrder); + + render(); + + // The component should render with custom order + expect(screen.getByText('Channels')).toBeInTheDocument(); + expect(screen.getByText('Settings')).toBeInTheDocument(); + }); + }); + + describe('Non-Admin User', () => { + beforeEach(() => { + useAuthStore.mockImplementation((selector) => { + const state = { + user: { user_level: USER_LEVELS.USER, custom_properties: {} }, + getNavOrder: mockGetNavOrder, + setNavOrder: mockSetNavOrder, + }; + return selector(state); + }); + }); + + it('renders only non-admin nav items for regular user', () => { + render(); + + // Non-admin items should be visible + expect(screen.getByText('Channels')).toBeInTheDocument(); + expect(screen.getByText('TV Guide')).toBeInTheDocument(); + expect(screen.getByText('Settings')).toBeInTheDocument(); + + // Admin-only items should not be visible + expect(screen.queryByText('VODs')).not.toBeInTheDocument(); + expect(screen.queryByText('M3U & EPG Manager')).not.toBeInTheDocument(); + expect(screen.queryByText('DVR')).not.toBeInTheDocument(); + expect(screen.queryByText('Stats')).not.toBeInTheDocument(); + expect(screen.queryByText('Plugins')).not.toBeInTheDocument(); + expect(screen.queryByText('Users')).not.toBeInTheDocument(); + expect(screen.queryByText('Logo Manager')).not.toBeInTheDocument(); + }); + + it('calls setNavOrder with user default order when reset', async () => { + const user = userEvent.setup(); + render(); + + const resetButton = screen.getByTestId('reset-button'); + await user.click(resetButton); + + await waitFor(() => { + expect(mockSetNavOrder).toHaveBeenCalledWith(DEFAULT_USER_ORDER); + }); + }); + }); +});