mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
feat: set default reminders for each calendar in calendar management #1209
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user