mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-19 17:47:40 +00:00
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:
parent
e876d6e926
commit
e2205fb454
2 changed files with 403 additions and 0 deletions
213
frontend/src/components/forms/settings/NavOrderForm.jsx
Normal file
213
frontend/src/components/forms/settings/NavOrderForm.jsx
Normal 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;
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue