feat: set default reminders for each calendar in calendar management #1209

This commit is contained in:
Linus Rath
2026-10-13 15:56:10 +02:00
parent 8181f5ba0c
commit 1730d7bab4
3 changed files with 228 additions and 3 deletions
@@ -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>): 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<typeof vi.fn>;
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(<CalendarManagementSettings />);
expect(screen.getAllByLabelText('default_alerts')).toHaveLength(1);
});
it('saves both lists with their actions and keeps alerts it cannot show', async () => {
render(<CalendarManagementSettings />);
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(<CalendarManagementSettings />);
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 });
});
});
@@ -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<Calendar, 'defaultAlertsWithTime' | 'defaultAlertsWithoutTime'>;
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<AlertRow[]>(timed.rows);
const [allDayRows, setAllDayRows] = useState<AlertRow[]>(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 (
<div className="space-y-3 p-3 rounded-md border border-primary/30 bg-accent/30">
<div>
<h4 className="text-sm font-medium">{t('default_alerts_title', { name: calendar.name })}</h4>
<p className="text-xs text-muted-foreground mt-0.5">{t('default_alerts_hint')}</p>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t('default_alerts_timed')}
</label>
<AlertListEditor
rows={timedRows}
onChange={setTimedRows}
keptCount={Object.keys(timed.preserved).length}
disabled={isLoading}
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t('default_alerts_all_day')}
</label>
<AlertListEditor
rows={allDayRows}
onChange={setAllDayRows}
keptCount={Object.keys(allDay.preserved).length}
disabled={isLoading}
/>
</div>
<div className="flex items-center gap-2 pt-1">
<button
onClick={submit}
disabled={isLoading}
className="px-3 py-1.5 text-xs font-medium bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50"
>
{t('save')}
</button>
<button
onClick={onCancel}
disabled={isLoading}
className="px-3 py-1.5 text-xs bg-muted text-foreground rounded-md hover:bg-accent"
>
{t('cancel')}
</button>
</div>
</div>
);
}
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<string | null>(null);
const [alertsEditingId, setAlertsEditingId] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [clearingId, setClearingId] = useState<string | null>(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 (
<CalendarDefaultAlertsForm
key={cal.id}
calendar={cal}
onSave={(updates) => handleDefaultAlertsSave(cal.id, updates)}
onCancel={() => setAlertsEditingId(null)}
isLoading={isLoading}
/>
);
}
if (deletingId === cal.id) {
return (
<div key={cal.id} className="flex items-center gap-3 py-2.5 px-3 bg-destructive/5 rounded-md border border-destructive/20">
@@ -560,6 +669,18 @@ export function CalendarManagementSettings() {
>
<Pencil className="w-3.5 h-3.5" />
</button>
{/* The owner's defaults; a sharee can't write the calendar. */}
{!cal.isShared && (
<button
type="button"
onClick={() => setAlertsEditingId(cal.id)}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title={t('default_alerts')}
aria-label={t('default_alerts')}
>
<Bell className="w-3.5 h-3.5" />
</button>
)}
{cal.myRights?.mayShare && !cal.isShared && !isSubscriptionCalendar(cal.id) && (
<button
type="button"
+7 -1
View File
@@ -3453,7 +3453,13 @@
"kind_events": "Events",
"kind_tasks": "Tasks",
"kind_both": "Events and tasks",
"kind_hint": "Decides which apps see this calendar when it is synced over CalDAV. It cannot be changed later."
"kind_hint": "Decides which apps see this calendar when it is synced over CalDAV. It cannot be changed later.",
"default_alerts": "Default reminders",
"default_alerts_title": "Default reminders for \"{name}\"",
"default_alerts_hint": "New events in this calendar get these reminders unless you change them in the event. Changing them here doesn't change existing events. Email reminders are sent by the mail server.",
"default_alerts_timed": "Events with a time",
"default_alerts_all_day": "All-day events",
"default_alerts_updated": "Default reminders updated"
},
"subscription": {
"title": "iCal Subscription",