diff --git a/components/calendar/__tests__/event-modal-alerts.test.tsx b/components/calendar/__tests__/event-modal-alerts.test.tsx new file mode 100644 index 000000000..b59522588 --- /dev/null +++ b/components/calendar/__tests__/event-modal-alerts.test.tsx @@ -0,0 +1,104 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { EventModal } from '../event-modal'; +import type { Calendar, CalendarEvent, CalendarEventAlert } from '@/lib/jmap/types'; + +// #1209: the reminder rows edit the action (notification or email), and +// alerts the rows can't show are saved back unchanged. + +const calendars = [ + { id: 'cal-1', name: 'Work', isDefault: true, myRights: { mayWriteAll: true, mayWriteOwn: true } }, +] as unknown as Calendar[]; + +const absolute: CalendarEventAlert = { + '@type': 'Alert', + trigger: { '@type': 'AbsoluteTrigger', when: '2026-11-19T08:00:00Z' }, + action: 'email', + acknowledged: null, + relatedTo: null, +}; + +function makeEvent(overrides: Partial = {}): CalendarEvent { + return { + id: 'evt-1', + '@type': 'Event', + uid: 'uid-1', + calendarIds: { 'cal-1': true }, + title: 'Review', + description: '', + start: '2026-11-20T10:00:00', + duration: 'PT1H', + timeZone: 'Europe/Berlin', + showWithoutTime: false, + participants: null, + recurrenceRules: null, + locations: null, + virtualLocations: null, + useDefaultAlerts: false, + alerts: { + a15: { + '@type': 'Alert', + trigger: { '@type': 'OffsetTrigger', offset: '-PT15M', relativeTo: 'start' }, + action: 'display', + acknowledged: null, + relatedTo: null, + }, + abs: absolute, + }, + ...overrides, + } as unknown as CalendarEvent; +} + +function renderEditor(event: CalendarEvent) { + const onSave = vi.fn().mockResolvedValue(undefined); + render(); + return onSave; +} + +async function savedData(onSave: ReturnType): Promise> { + fireEvent.click(screen.getByText('form.save')); + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + return onSave.mock.calls[0][0]; +} + +describe('EventModal reminder actions', () => { + it('saves a row switched to email with action email', async () => { + const onSave = renderEditor(makeEvent()); + + fireEvent.change(screen.getByLabelText('alerts.action'), { target: { value: 'email' } }); + const data = await savedData(onSave); + + const offsets = Object.values(data.alerts!).filter((a) => a.trigger['@type'] === 'OffsetTrigger'); + expect(offsets).toHaveLength(1); + expect(offsets[0]).toMatchObject({ action: 'email', trigger: { offset: '-PT15M', relativeTo: 'start' } }); + }); + + it('opens an email alert as an email row', () => { + renderEditor(makeEvent({ + alerts: { + e: { ...absolute, trigger: { '@type': 'OffsetTrigger', offset: '-PT1H', relativeTo: 'start' } }, + }, + })); + expect((screen.getByLabelText('alerts.action') as HTMLSelectElement).value).toBe('email'); + expect((screen.getByLabelText('alerts.unit') as HTMLSelectElement).value).toBe('hours'); + }); + + it('keeps an absolute alert through a save', async () => { + const onSave = renderEditor(makeEvent()); + + fireEvent.click(screen.getByLabelText('alerts.remove')); + const data = await savedData(onSave); + + expect(data.alerts).toEqual({ abs: absolute }); + }); + + it('keeps an alert after the start instead of turning it into one before', async () => { + const after: CalendarEventAlert = { ...absolute, action: 'display', trigger: { '@type': 'OffsetTrigger', offset: 'PT10M', relativeTo: 'start' } }; + const onSave = renderEditor(makeEvent({ alerts: { after } })); + + expect(screen.queryByLabelText('alerts.unit')).toBeNull(); + const data = await savedData(onSave); + + expect(data.alerts).toEqual({ after }); + }); +}); diff --git a/components/calendar/alert-list-editor.tsx b/components/calendar/alert-list-editor.tsx new file mode 100644 index 000000000..7245d06bc --- /dev/null +++ b/components/calendar/alert-list-editor.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { Input } from "@/components/ui/input"; +import { Trash2, Plus } from "@/components/icons"; +import { + newAlertRow, + type AlertAction, + type AlertRow, + type AlertUnit, +} from "@/lib/calendar-alerts"; + +type CalendarT = ReturnType; + +const selectClass = + "rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"; + +/** "15 minutes before", plus "by email" for email alerts. */ +export function formatAlertRowLabel(row: Omit, t: CalendarT): string { + let label: string; + switch (row.unit) { + case "at_time": label = t("alerts.at_time"); break; + case "minutes": label = t("alerts.minutes_before", { count: row.value }); break; + case "hours": label = t("alerts.hours_before", { count: row.value }); break; + case "days": label = t("alerts.days_before", { count: row.value }); break; + case "weeks": label = t("alerts.weeks_before", { count: row.value }); break; + } + return row.action === "email" ? t("alerts.by_email", { label }) : label; +} + +interface AlertListEditorProps { + rows: AlertRow[]; + onChange: (rows: AlertRow[]) => void; + /** Alerts the rows can't show (absolute, after the start); saved unchanged. */ + keptCount?: number; + disabled?: boolean; +} + +/** + * Edits reminders as rows of amount, unit and action (notification or email). + * Each row is an offset before the start; alerts that don't fit stay with the + * caller and are only counted here. + */ +export function AlertListEditor({ rows, onChange, keptCount = 0, disabled = false }: AlertListEditorProps) { + const t = useTranslations("calendar"); + + const update = (id: string, patch: Partial>) => { + onChange(rows.map((r) => (r.id === id ? { ...r, ...patch } : r))); + }; + + return ( +
+ {rows.length === 0 && keptCount === 0 ? ( +

{t("alerts.none")}

+ ) : ( +
+ {rows.map((row) => ( +
+ {row.unit !== "at_time" && ( + { + const n = parseInt(e.target.value, 10); + update(row.id, { value: Number.isFinite(n) ? Math.max(1, n) : 1 }); + }} + className="w-20" + aria-label={t("alerts.amount")} + /> + )} + + + +
+ ))} + {keptCount > 0 && ( +

{t("alerts.kept", { count: keptCount })}

+ )} +
+ )} + +
+ ); +} diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index ee4b98240..8cf37c3c5 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -6,9 +6,11 @@ import { toast } from "@/stores/toast-store"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { LinkifiedText } from "@/components/ui/linkified-text"; -import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus } from "@/components/icons"; +import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft } from "@/components/icons"; import { format, parseISO, addHours, isSameDay } from "date-fns"; import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, CalendarRecurrenceRule } from "@/lib/jmap/types"; +import { AlertListEditor, formatAlertRowLabel } from "./alert-list-editor"; +import { alertsToRows, describeAlert, rowsToAlerts, type AlertRow } from "@/lib/calendar-alerts"; import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from "./recurrence-editor"; import { parseDuration, getEventColor } from "./event-card"; import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils"; @@ -109,65 +111,6 @@ function buildDuration(startDate: Date, endDate: Date): string { type RecurrenceOption = "none" | "daily" | "weekly" | "monthly" | "yearly" | "custom"; -type AlertUnit = "at_time" | "minutes" | "hours" | "days" | "weeks"; - -interface AlertRow { - id: string; - value: number; - unit: AlertUnit; -} - -let alertRowSeq = 0; -function newAlertRow(value: number, unit: AlertUnit): AlertRow { - alertRowSeq += 1; - return { id: `r${alertRowSeq}`, value, unit }; -} - -function alertRowToOffset(row: AlertRow): string | null { - if (row.unit === "at_time") return "PT0S"; - const v = Math.max(0, Math.floor(row.value)); - if (!Number.isFinite(v) || v <= 0) return null; - switch (row.unit) { - case "minutes": return `-PT${v}M`; - case "hours": return `-PT${v}H`; - case "days": return `-P${v}D`; - case "weeks": return `-P${v}W`; - } -} - -function offsetToAlertRow(offset: string): AlertRow | null { - if (offset === "PT0S" || offset === "P0D" || offset === "PT0M") { - return newAlertRow(0, "at_time"); - } - let m = offset.match(/^-?P(\d+)W$/); - if (m) return newAlertRow(parseInt(m[1], 10), "weeks"); - m = offset.match(/^-?P(\d+)D$/); - if (m) return newAlertRow(parseInt(m[1], 10), "days"); - m = offset.match(/^-?PT(\d+)H$/); - if (m) return newAlertRow(parseInt(m[1], 10), "hours"); - m = offset.match(/^-?PT(\d+)M$/); - if (m) { - const mins = parseInt(m[1], 10); - if (mins > 0 && mins % 1440 === 0) return newAlertRow(mins / 1440, "days"); - if (mins > 0 && mins % 60 === 0) return newAlertRow(mins / 60, "hours"); - return newAlertRow(mins, "minutes"); - } - return null; -} - -function formatAlertRowLabel( - row: { value: number; unit: AlertUnit }, - t: ReturnType -): string { - if (row.unit === "at_time") return t("alerts.at_time"); - switch (row.unit) { - case "minutes": return t("alerts.minutes_before", { count: row.value }); - case "hours": return t("alerts.hours_before", { count: row.value }); - case "days": return t("alerts.days_before", { count: row.value }); - case "weeks": return t("alerts.weeks_before", { count: row.value }); - } -} - function formatDurationDisplay(minutes: number): string { if (minutes < 60) return `${minutes}min`; const h = Math.floor(minutes / 60); @@ -180,8 +123,7 @@ function getAlertLabel(event: CalendarEvent, t: ReturnType (customRule ? buildRecurrenceSummary(customRule, t, locale) : null), [customRule, t, locale] ); - const preservedAlertsRef = useRef>({}); - const [alertRows, setAlertRows] = useState(() => { - if (!event?.alerts) return []; - const rows: AlertRow[] = []; - for (const [id, alert] of Object.entries(event.alerts)) { - // Preserve alerts we can't represent in this UI (absolute triggers, - // email actions, offsets with non-canonical shapes) so they survive a save. - if (alert.trigger["@type"] !== "OffsetTrigger" || alert.action !== "display") { - preservedAlertsRef.current[id] = alert; - continue; - } - const row = offsetToAlertRow(alert.trigger.offset); - if (!row) { - preservedAlertsRef.current[id] = alert; - continue; - } - rows.push(row); - } - return rows; - }); - - const addAlertRow = useCallback(() => { - setAlertRows((prev) => [...prev, newAlertRow(10, "minutes")]); - }, []); - const updateAlertRow = useCallback((id: string, patch: Partial>) => { - setAlertRows((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r))); - }, []); - const removeAlertRow = useCallback((id: string) => { - setAlertRows((prev) => prev.filter((r) => r.id !== id)); - }, []); + // Alerts the rows can't represent (absolute triggers, offsets after the + // start or relative to the end) are kept here so they survive a save. + const [initialAlerts] = useState(() => alertsToRows(event?.alerts)); + const preservedAlertsRef = useRef>(initialAlerts.preserved); + const [alertRows, setAlertRows] = useState(initialAlerts.rows); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [isSaving, setIsSaving] = useState(false); // Set synchronously, so a second save in the same tick is refused too. @@ -612,21 +529,7 @@ export function EventModal({ if (event.excludedRecurrenceRules) data.excludedRecurrenceRules = null; } - const builtAlerts: Record = { ...preservedAlertsRef.current }; - let alertIdx = 0; - for (const row of alertRows) { - const offset = alertRowToOffset(row); - if (offset === null) continue; - let key = `alert${++alertIdx}`; - while (key in builtAlerts) key = `alert${++alertIdx}`; - builtAlerts[key] = { - "@type": "Alert", - trigger: { "@type": "OffsetTrigger", offset, relativeTo: "start" }, - action: "display", - acknowledged: null, - relatedTo: null, - }; - } + const builtAlerts = rowsToAlerts(alertRows, preservedAlertsRef.current); if (Object.keys(builtAlerts).length > 0) { data.alerts = builtAlerts; } else if (event && event.alerts && Object.keys(event.alerts).length > 0) { @@ -1343,64 +1246,11 @@ export function EventModal({
- {alertRows.length === 0 ? ( -

{t("alerts.none")}

- ) : ( -
- {alertRows.map((row) => ( -
- {row.unit !== "at_time" && ( - { - const n = parseInt(e.target.value, 10); - updateAlertRow(row.id, { value: Number.isFinite(n) ? Math.max(1, n) : 1 }); - }} - className="w-20" - aria-label={t("alerts.amount")} - /> - )} - - -
- ))} -
- )} - +
{attendees.length > 0 && ( diff --git a/locales/en/common.json b/locales/en/common.json index b68ba37ba..3deee60d9 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -3243,7 +3243,12 @@ "add": "Add reminder", "remove": "Remove reminder", "amount": "Reminder amount", - "unit": "Reminder unit" + "unit": "Reminder unit", + "action": "Reminder type", + "action_display": "Notification", + "action_email": "Email", + "by_email": "{label} by email", + "kept": "{count, plural, one {# more reminder that can't be edited here is kept} other {# more reminders that can't be edited here are kept}}" }, "settings": { "limit_hours": "Limit visible hours",