From de388a1d1273ffe8b76151f91440b64106b7d4d8 Mon Sep 17 00:00:00 2001 From: lucletoffe <15689941+lucletoffe@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:02:23 +0200 Subject: [PATCH] feat(calendar): order calendars by sortOrder and let users reorder them Calendars were listed in the order the server returns them, which for Stalwart is creation order, and the sortOrder property was never read. Follow the JMAP Calendars rule instead: lower sortOrder first, equal sortOrder by name (case- and accent-insensitive, numbers by value). The store keeps the list sorted, so the sidebar, the calendar pickers and the settings all show the same order; each account's calendars stay together. Calendar settings gain move up / move down buttons, visible on touch screens too, and drag and drop for the user's own calendars. A move renumbers that account's calendars 1, 2, 3... and writes sortOrder only where it changes. The renumbering also covers the account's calendars the settings list does not show (iCal subscriptions), so they keep their place relative to the others. Shared calendars are not offered for reordering, their order is the owner's. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../calendar-order-settings.test.tsx | 79 ++++++++++++ .../settings/calendar-management-settings.tsx | 84 +++++++++++- lib/__tests__/calendar-sort.test.ts | 120 +++++++++++++++++ lib/calendar-sort.ts | 70 ++++++++++ locales/ar/common.json | 2 + locales/ca/common.json | 2 + locales/cs/common.json | 2 + locales/da/common.json | 2 + locales/de/common.json | 2 + locales/en/common.json | 2 + locales/es/common.json | 2 + locales/fa/common.json | 2 + locales/fr/common.json | 2 + locales/he/common.json | 2 + locales/hu/common.json | 2 + locales/it/common.json | 2 + locales/ja/common.json | 2 + locales/ko/common.json | 2 + locales/lv/common.json | 2 + locales/mn/common.json | 2 + locales/nb/common.json | 2 + locales/nl/common.json | 2 + locales/pl/common.json | 2 + locales/pt/common.json | 2 + locales/ro/common.json | 2 + locales/ru/common.json | 2 + locales/sk/common.json | 2 + locales/tr/common.json | 2 + locales/uk/common.json | 2 + locales/zh-TW/common.json | 2 + locales/zh/common.json | 2 + .../calendar-store-sort-order.test.ts | 121 ++++++++++++++++++ stores/calendar-store.ts | 50 ++++++-- 33 files changed, 563 insertions(+), 15 deletions(-) create mode 100644 components/settings/__tests__/calendar-order-settings.test.tsx create mode 100644 lib/__tests__/calendar-sort.test.ts create mode 100644 lib/calendar-sort.ts create mode 100644 stores/__tests__/calendar-store-sort-order.test.ts 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. +