From 1730d7bab461a362fcabe76bacce8cb2dc5f2ffd Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:30:51 +0200 Subject: [PATCH] feat: set default reminders for each calendar in calendar management #1209 --- .../calendar-default-alerts-settings.test.tsx | 98 ++++++++++++++ .../settings/calendar-management-settings.tsx | 125 +++++++++++++++++- locales/en/common.json | 8 +- 3 files changed, 228 insertions(+), 3 deletions(-) create mode 100644 components/settings/__tests__/calendar-default-alerts-settings.test.tsx diff --git a/components/settings/__tests__/calendar-default-alerts-settings.test.tsx b/components/settings/__tests__/calendar-default-alerts-settings.test.tsx new file mode 100644 index 000000000..c80aea02b --- /dev/null +++ b/components/settings/__tests__/calendar-default-alerts-settings.test.tsx @@ -0,0 +1,98 @@ +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, CalendarEventAlert } from '@/lib/jmap/types'; + +// #1209: each calendar the user owns gets editable default reminders for +// timed and all-day events. + +const absolute: CalendarEventAlert = { + '@type': 'Alert', + trigger: { '@type': 'AbsoluteTrigger', when: '2026-11-19T08:00:00Z' }, + action: 'display', + acknowledged: null, + relatedTo: null, +}; + +function makeCalendar(overrides: Partial): Calendar { + return { + id: 'cal-1', + name: 'Work', + color: '#3b82f6', + isDefault: true, + isShared: false, + shareWith: null, + myRights: { mayWriteAll: true, mayWriteOwn: true, mayShare: true, mayDelete: true }, + defaultAlertsWithTime: null, + defaultAlertsWithoutTime: null, + ...overrides, + } as unknown as Calendar; +} + +const client = { getAccountId: () => 'acct' }; +let updateCalendar: ReturnType; + +beforeEach(() => { + updateCalendar = vi.fn().mockResolvedValue(undefined); + useAuthStore.setState({ client: client as never, serverUrl: null as never, username: null as never }); + useCalendarStore.setState({ + calendars: [ + makeCalendar({ defaultAlertsWithoutTime: { abs: absolute } }), + makeCalendar({ id: 'cal-2', name: 'Team', isDefault: false, isShared: true }), + ], + icalSubscriptions: [], + updateCalendar, + } as never); +}); + +describe('Calendar default reminders settings', () => { + it('offers default reminders only for calendars the user owns', () => { + render(); + expect(screen.getAllByLabelText('default_alerts')).toHaveLength(1); + }); + + it('saves both lists with their actions and keeps alerts it cannot show', async () => { + render(); + fireEvent.click(screen.getByLabelText('default_alerts')); + + // Timed list first, then all-day. + const [addTimed, addAllDay] = screen.getAllByText('alerts.add'); + fireEvent.click(addTimed); + fireEvent.change(screen.getByLabelText('alerts.action'), { target: { value: 'email' } }); + fireEvent.click(addAllDay); + fireEvent.change(screen.getAllByLabelText('alerts.unit')[1], { target: { value: 'days' } }); + fireEvent.change(screen.getAllByLabelText('alerts.amount')[1], { target: { value: '1' } }); + fireEvent.click(screen.getByText('save')); + + await waitFor(() => expect(updateCalendar).toHaveBeenCalledTimes(1)); + const [, calendarId, updates] = updateCalendar.mock.calls[0]; + expect(calendarId).toBe('cal-1'); + expect(Object.values(updates.defaultAlertsWithTime)).toEqual([ + expect.objectContaining({ action: 'email', trigger: { '@type': 'OffsetTrigger', offset: '-PT10M', relativeTo: 'start' } }), + ]); + expect(updates.defaultAlertsWithoutTime.abs).toBe(absolute); + expect(Object.values(updates.defaultAlertsWithoutTime)).toContainEqual( + expect.objectContaining({ action: 'display', trigger: expect.objectContaining({ offset: '-P1D' }) }), + ); + }); + + it('clears a list by sending null once its last reminder is removed', async () => { + useCalendarStore.setState({ + calendars: [makeCalendar({ + defaultAlertsWithTime: { + a: { ...absolute, trigger: { '@type': 'OffsetTrigger', offset: '-PT15M', relativeTo: 'start' } }, + }, + })], + } as never); + render(); + fireEvent.click(screen.getByLabelText('default_alerts')); + + fireEvent.click(screen.getByLabelText('alerts.remove')); + fireEvent.click(screen.getByText('save')); + + await waitFor(() => expect(updateCalendar).toHaveBeenCalledTimes(1)); + expect(updateCalendar.mock.calls[0][2]).toEqual({ defaultAlertsWithTime: null, defaultAlertsWithoutTime: null }); + }); +}); diff --git a/components/settings/calendar-management-settings.tsx b/components/settings/calendar-management-settings.tsx index 1dfddc219..5c88d38d2 100644 --- a/components/settings/calendar-management-settings.tsx +++ b/components/settings/calendar-management-settings.tsx @@ -4,12 +4,14 @@ import { useState, useRef, useEffect, useMemo } from 'react'; import { useTranslations } from 'next-intl'; import { useCalendarStore } from '@/stores/calendar-store'; import { CALENDAR_KIND_COMPONENTS, CalendarKindPicker, type CalendarKind } from '@/components/calendar/calendar-kind-picker'; -import type { CalendarComponentType } from '@/lib/jmap/types'; +import type { Calendar, CalendarComponentType } from '@/lib/jmap/types'; 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 } from '@/components/icons'; +import { Plus, Pencil, Trash2, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser, Users, Bell } 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'; import type { CalendarRights } from '@/lib/jmap/types'; import { cn, formatDateTime, redactUrlCredentials } from '@/lib/utils'; @@ -150,6 +152,86 @@ export function CalendarEditForm({ export { CalendarColorPicker, CALENDAR_COLORS }; +type DefaultAlertsUpdate = Pick; + +export function CalendarDefaultAlertsForm({ + calendar, + onSave, + onCancel, + isLoading, +}: { + calendar: Calendar; + onSave: (updates: DefaultAlertsUpdate) => void; + onCancel: () => void; + isLoading: boolean; +}) { + const t = useTranslations('calendar.management'); + // Alerts the rows can't show are written back unchanged. + const [timed] = useState(() => alertsToRows(calendar.defaultAlertsWithTime)); + const [allDay] = useState(() => alertsToRows(calendar.defaultAlertsWithoutTime)); + const [timedRows, setTimedRows] = useState(timed.rows); + const [allDayRows, setAllDayRows] = useState(allDay.rows); + + const toMap = (rows: AlertRow[], preserved: typeof timed.preserved) => { + const map = rowsToAlerts(rows, preserved); + return Object.keys(map).length > 0 ? map : null; + }; + const submit = () => onSave({ + defaultAlertsWithTime: toMap(timedRows, timed.preserved), + defaultAlertsWithoutTime: toMap(allDayRows, allDay.preserved), + }); + + return ( +
+
+

{t('default_alerts_title', { name: calendar.name })}

+

{t('default_alerts_hint')}

+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ ); +} + export function CalendarManagementSettings() { const t = useTranslations('calendar.management'); const { client, serverUrl, username } = useAuthStore(); @@ -168,6 +250,7 @@ export function CalendarManagementSettings() { const [isCreating, setIsCreating] = useState(false); const [editingId, setEditingId] = useState(null); + const [alertsEditingId, setAlertsEditingId] = useState(null); const [deletingId, setDeletingId] = useState(null); const [clearingId, setClearingId] = useState(null); const [isLoading, setIsLoading] = useState(false); @@ -329,6 +412,20 @@ export function CalendarManagementSettings() { } }; + const handleDefaultAlertsSave = async (calendarId: string, updates: DefaultAlertsUpdate) => { + if (!client) return; + setIsLoading(true); + try { + await updateCalendar(client, calendarId, updates); + setAlertsEditingId(null); + toast.success(t('default_alerts_updated')); + } catch { + toast.error(t('error_update')); + } finally { + setIsLoading(false); + } + }; + const handleColorChange = async (calendarId: string, color: string) => { if (!client) return; // Shared calendars: recolor locally only - the viewer usually can't write @@ -424,6 +521,18 @@ export function CalendarManagementSettings() { ); } + if (alertsEditingId === cal.id) { + return ( + handleDefaultAlertsSave(cal.id, updates)} + onCancel={() => setAlertsEditingId(null)} + isLoading={isLoading} + /> + ); + } + if (deletingId === cal.id) { return (
@@ -560,6 +669,18 @@ export function CalendarManagementSettings() { > + {/* The owner's defaults; a sharee can't write the calendar. */} + {!cal.isShared && ( + + )} {cal.myRights?.mayShare && !cal.isShared && !isSubscriptionCalendar(cal.id) && (