diff --git a/components/settings/__tests__/calendar-order-settings.test.tsx b/components/settings/__tests__/calendar-order-settings.test.tsx new file mode 100644 index 000000000..6c701f407 --- /dev/null +++ b/components/settings/__tests__/calendar-order-settings.test.tsx @@ -0,0 +1,79 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { CalendarManagementSettings } from '../calendar-management-settings'; +import { useCalendarStore } from '@/stores/calendar-store'; +import { useAuthStore } from '@/stores/auth-store'; +import type { Calendar } from '@/lib/jmap/types'; + +function makeCalendar(overrides: Partial): Calendar { + return { + id: 'cal-1', + name: 'Work', + color: '#3b82f6', + sortOrder: 0, + isDefault: false, + isShared: false, + shareWith: null, + myRights: { mayWriteAll: true, mayWriteOwn: true, mayShare: true, mayDelete: true }, + ...overrides, + } as unknown as Calendar; +} + +const client = { getAccountId: () => 'acct', getPrincipals: vi.fn().mockResolvedValue([]) }; +let reorderCalendars: ReturnType; + +beforeEach(() => { + reorderCalendars = vi.fn().mockResolvedValue(undefined); + useAuthStore.setState({ client: client as never, serverUrl: null as never, username: null as never }); + useCalendarStore.setState({ + calendars: [ + makeCalendar({ id: 'work', name: 'Work', sortOrder: 1 }), + makeCalendar({ id: 'home', name: 'Home', sortOrder: 2 }), + makeCalendar({ id: 'holidays', name: 'Holidays', sortOrder: 3 }), + makeCalendar({ id: 'x:team', name: 'Team', isShared: true, accountId: 'x' }), + ], + icalSubscriptions: [], + reorderCalendars, + } as never); +}); + +const row = (name: string) => screen.getAllByTestId('calendar-settings-item').find((el) => el.dataset.calendarName === name)!; +const buttonIn = (name: string, label: string) => + Array.from(row(name).querySelectorAll('button')).find((b) => b.getAttribute('aria-label') === label)!; + +describe('Calendar order in settings', () => { + it('moves a calendar up past its neighbour', async () => { + render(); + fireEvent.click(buttonIn('Holidays', 'move_up')); + await waitFor(() => expect(reorderCalendars).toHaveBeenCalledWith(client, ['work', 'holidays', 'home'])); + }); + + it('moves a calendar down past its neighbour', async () => { + render(); + fireEvent.click(buttonIn('Work', 'move_down')); + await waitFor(() => expect(reorderCalendars).toHaveBeenCalledWith(client, ['home', 'work', 'holidays'])); + }); + + it('disables moving the first calendar up and the last one down', () => { + render(); + expect(buttonIn('Work', 'move_up')).toBeDisabled(); + expect(buttonIn('Holidays', 'move_down')).toBeDisabled(); + }); + + it('offers no reordering for a calendar shared with the user', () => { + render(); + expect(buttonIn('Team', 'move_up')).toBeUndefined(); + expect(row('Team')).not.toHaveAttribute('draggable', 'true'); + }); + + it('reorders by drag and drop', async () => { + render(); + const dataTransfer = { effectAllowed: '', dropEffect: '', setData: vi.fn() }; + fireEvent.dragStart(row('Holidays'), { dataTransfer }); + // Firefox starts no drag unless data is set. + expect(dataTransfer.setData).toHaveBeenCalledWith('text/plain', 'holidays'); + fireEvent.dragOver(row('Work'), { dataTransfer }); + fireEvent.drop(row('Work'), { dataTransfer }); + await waitFor(() => expect(reorderCalendars).toHaveBeenCalledWith(client, ['holidays', 'work', 'home'])); + }); +}); diff --git a/components/settings/calendar-management-settings.tsx b/components/settings/calendar-management-settings.tsx index b6e9d6681..5f6cc8219 100644 --- a/components/settings/calendar-management-settings.tsx +++ b/components/settings/calendar-management-settings.tsx @@ -9,7 +9,7 @@ import { useAuthStore } from '@/stores/auth-store'; import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot'; import { toast } from '@/stores/toast-store'; import { SettingsSection } from './settings-section'; -import { Plus, Pencil, Trash2, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser, Users, Bell } from '@/components/icons'; +import { Plus, Pencil, Trash2, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser, Users, Bell, ArrowUp, ArrowDown, GripVertical } from '@/components/icons'; import { AlertListEditor } from '@/components/calendar/alert-list-editor'; import { alertsToRows, rowsToAlerts, type AlertRow } from '@/lib/calendar-alerts'; import { ShareCollectionDialog } from './share-collection-dialog'; @@ -22,6 +22,7 @@ import { useManagedAccountStore } from '@/stores/managed-account-store'; import { apiFetch } from '@/lib/browser-navigation'; import { CALENDAR_COLORS, sharedCalendarColorKey } from '@/lib/shared-calendar-colors'; import { IS_LITE } from '@/lib/lite'; +import { calendarAccountKey, moveCalendar } from '@/lib/calendar-sort'; function CalendarColorPicker({ value, @@ -233,7 +234,7 @@ export function CalendarManagementSettings() { const t = useTranslations('calendar.management'); const { client, serverUrl, username } = useAuthStore(); const managedAccountId = useManagedAccountStore((s) => s.managedAccountId); - const { calendars, updateCalendar, shareCalendar, createCalendar, removeCalendar, clearCalendarEvents, fetchCalendars, icalSubscriptions: allSubs, removeICalSubscription, refreshICalSubscription, isSubscriptionCalendar } = useCalendarStore(); + const { calendars, updateCalendar, reorderCalendars, shareCalendar, createCalendar, removeCalendar, clearCalendarEvents, fetchCalendars, icalSubscriptions: allSubs, removeICalSubscription, refreshICalSubscription, isSubscriptionCalendar } = useCalendarStore(); // Subscriptions are persisted globally but scoped per JMAP account via // accountId. Legacy entries with no accountId show in the active account. const currentAccountId = client?.getAccountId(); @@ -264,6 +265,14 @@ export function CalendarManagementSettings() { const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors); const setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor); const colorPickerRef = useRef(null); + const [draggingId, setDraggingId] = useState(null); + + const listedCalendars = calendars + .filter(cal => !isSubscriptionCalendar(cal.id)) + .filter(cal => !managedAccountId || cal.accountId === managedAccountId); + // Only the user's own calendars are offered for reordering; a shared + // calendar's order is left to its owner. + const orderableCalendars = listedCalendars.filter(cal => !cal.isShared); // Load calendars if not yet loaded useEffect(() => { @@ -443,6 +452,19 @@ export function CalendarManagementSettings() { setColorPickerId(null); }; + // Move a calendar next to `targetId` (before it when moving up, after it + // when moving down) and store the new order as sortOrder. + const handleMove = async (calendarId: string, targetId: string | undefined) => { + if (!client || !targetId) return; + const orderedIds = moveCalendar(calendars, calendarId, targetId); + if (!orderedIds) return; + try { + await reorderCalendars(client, orderedIds); + } catch { + toast.error(t('error_update')); + } + }; + const handleDelete = async (calendarId: string) => { if (!client) return; setIsLoading(true); @@ -500,11 +522,14 @@ export function CalendarManagementSettings() { return (
- {calendars - .filter(cal => !isSubscriptionCalendar(cal.id)) - .filter(cal => !managedAccountId || cal.accountId === managedAccountId) - .map((cal) => { + {listedCalendars.map((cal) => { const color = (cal.isShared && sharedCalendarColors[sharedCalendarColorKey(cal)]) || cal.color || '#3b82f6'; + // Up and down step over the neighbours of the same account. + const siblings = cal.isShared ? [] : orderableCalendars.filter(c => calendarAccountKey(c) === calendarAccountKey(cal)); + const orderIndex = siblings.indexOf(cal); + const isOrderable = siblings.length > 1; + const previousId = siblings[orderIndex - 1]?.id; + const nextId = siblings[orderIndex + 1]?.id; if (editingId === cal.id) { return ( @@ -581,8 +606,27 @@ export function CalendarManagementSettings() { return (
{ + setDraggingId(cal.id); + // Firefox starts no drag without data. + e.dataTransfer.setData('text/plain', cal.id); + e.dataTransfer.effectAllowed = 'move'; + } : undefined} + onDragOver={isOrderable && draggingId ? (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } : undefined} + onDrop={isOrderable && draggingId ? (e) => { e.preventDefault(); void handleMove(draggingId, cal.id); setDraggingId(null); } : undefined} + onDragEnd={() => setDraggingId(null)} + className={cn( + "flex items-center gap-3 py-2.5 px-3 rounded-md border border-border bg-background group", + draggingId === cal.id && "opacity-50", + )} > + {isOrderable && ( + // No HTML drag and drop on touch screens: the arrows do it there. +