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 <noreply@anthropic.com>
This commit is contained in:
Jeff Casimir 2026-01-31 13:45:13 -07:00 committed by SergeantPanda
parent e876d6e926
commit e2205fb454
2 changed files with 403 additions and 0 deletions

View file

@ -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 (
<Box
ref={setNodeRef}
style={{
...style,
padding: '10px 12px',
border: '1px solid #444',
borderRadius: '6px',
backgroundColor: isDragging ? '#3A3A3E' : '#2A2A2E',
marginBottom: 6,
}}
>
<Group justify="space-between">
<Group gap="sm">
<ActionIcon
{...attributes}
{...listeners}
variant="transparent"
size="sm"
style={{ cursor: 'grab' }}
>
<GripVertical size={16} color="#888" />
</ActionIcon>
{IconComponent && <IconComponent size={18} color="#ccc" />}
<Text size="sm" c="gray.3">
{item.label}
</Text>
</Group>
</Group>
</Box>
);
};
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 (
<Stack gap="md">
<Text size="sm" c="dimmed">
Drag and drop to reorder the sidebar navigation items.
</Text>
<DndContext
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragEnd={handleDragEnd}
sensors={sensors}
>
<SortableContext
items={items.map((item) => item.id)}
strategy={verticalListSortingStrategy}
>
{items.map((item) => (
<DraggableNavItem key={item.id} item={item} />
))}
</SortableContext>
</DndContext>
<Group justify="flex-end">
<Button
variant="subtle"
color="gray"
onClick={handleReset}
disabled={isSaving}
>
Reset to Default
</Button>
</Group>
</Stack>
);
};
export default NavOrderForm;

View file

@ -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 }) => <div data-testid="dnd-context">{children}</div>,
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 }) => <div data-testid="sortable-context">{children}</div>,
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 }) => <div {...props}>{children}</div>,
Button: ({ children, onClick, disabled, ...props }) => (
<button onClick={onClick} disabled={disabled} data-testid="reset-button">
{children}
</button>
),
Text: ({ children }) => <span>{children}</span>,
Group: ({ children }) => <div>{children}</div>,
ActionIcon: ({ children, ...props }) => <button {...props}>{children}</button>,
Stack: ({ children }) => <div>{children}</div>,
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(<NavOrderForm active={true} />);
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(<NavOrderForm active={true} />);
expect(screen.getByTestId('reset-button')).toBeInTheDocument();
expect(screen.getByText('Reset to Default')).toBeInTheDocument();
});
it('does not render when not active', () => {
render(<NavOrderForm active={false} />);
expect(screen.queryByText('Channels')).not.toBeInTheDocument();
});
it('calls setNavOrder when reset button is clicked', async () => {
const user = userEvent.setup();
render(<NavOrderForm active={true} />);
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(<NavOrderForm active={true} />);
// 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(<NavOrderForm active={true} />);
// 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(<NavOrderForm active={true} />);
const resetButton = screen.getByTestId('reset-button');
await user.click(resetButton);
await waitFor(() => {
expect(mockSetNavOrder).toHaveBeenCalledWith(DEFAULT_USER_ORDER);
});
});
});
});