mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: start new events on the calendar's default reminders #1209
This commit is contained in:
@@ -6,6 +6,18 @@ import type { Calendar, CalendarEvent, CalendarEventAlert } from '@/lib/jmap/typ
|
||||
// #1209: the reminder rows edit the action (notification or email), and
|
||||
// alerts the rows can't show are saved back unchanged.
|
||||
|
||||
// Keep the values in the label so "15 minutes" and "1 hour" differ.
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string, values?: Record<string, unknown>) =>
|
||||
values ? `${key}(${Object.values(values).join(',')})` : key,
|
||||
useLocale: () => 'en',
|
||||
useFormatter: () => ({
|
||||
dateTime: (d: Date | string) => String(d),
|
||||
relativeTime: (d: Date | string) => String(d),
|
||||
number: (n: number) => String(n),
|
||||
}),
|
||||
}));
|
||||
|
||||
const calendars = [
|
||||
{ id: 'cal-1', name: 'Work', isDefault: true, myRights: { mayWriteAll: true, mayWriteOwn: true } },
|
||||
] as unknown as Calendar[];
|
||||
@@ -102,3 +114,119 @@ describe('EventModal reminder actions', () => {
|
||||
expect(data.alerts).toEqual({ after });
|
||||
});
|
||||
});
|
||||
|
||||
// Calendar default alerts: a new event uses them; Stalwart copies them into
|
||||
// the event on create but not when the flag is set on an update.
|
||||
describe('EventModal calendar default alerts', () => {
|
||||
const offset = (o: string, action: CalendarEventAlert['action'] = 'display'): CalendarEventAlert => ({
|
||||
'@type': 'Alert',
|
||||
trigger: { '@type': 'OffsetTrigger', offset: o, relativeTo: 'start' },
|
||||
action,
|
||||
acknowledged: null,
|
||||
relatedTo: null,
|
||||
});
|
||||
const work = {
|
||||
id: 'cal-1', name: 'Work', isDefault: true, myRights: { mayWriteAll: true, mayWriteOwn: true },
|
||||
defaultAlertsWithTime: { w1: offset('-PT15M') },
|
||||
defaultAlertsWithoutTime: { w2: offset('-P1D', 'email') },
|
||||
};
|
||||
const home = {
|
||||
id: 'cal-2', name: 'Home', isDefault: false, myRights: { mayWriteAll: true, mayWriteOwn: true },
|
||||
defaultAlertsWithTime: { h1: offset('-PT1H', 'email') },
|
||||
defaultAlertsWithoutTime: null,
|
||||
};
|
||||
const withDefaults = [work, home] as unknown as Calendar[];
|
||||
|
||||
function renderNew() {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(
|
||||
<EventModal calendars={withDefaults} defaultDate={new Date('2026-11-20T10:00:00')} defaultCalendarId="cal-1" onSave={onSave} onClose={vi.fn()} />,
|
||||
);
|
||||
fireEvent.change(screen.getByPlaceholderText('form.title'), { target: { value: 'Sync' } });
|
||||
return onSave;
|
||||
}
|
||||
|
||||
function renderExisting(event: CalendarEvent, startInEditMode = true) {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<EventModal event={event} startInEditMode={startInEditMode} calendars={withDefaults} onSave={onSave} onClose={vi.fn()} />);
|
||||
return onSave;
|
||||
}
|
||||
|
||||
it('starts a new event on the calendar defaults and saves only the flag', async () => {
|
||||
const onSave = renderNew();
|
||||
|
||||
expect(screen.getByText('alerts.calendar_defaults')).toBeTruthy();
|
||||
expect(screen.getByText('alerts.minutes_before(15)')).toBeTruthy();
|
||||
expect(screen.queryByLabelText('alerts.unit')).toBeNull();
|
||||
|
||||
const data = await savedData(onSave);
|
||||
expect(data.useDefaultAlerts).toBe(true);
|
||||
expect(data).not.toHaveProperty('alerts');
|
||||
});
|
||||
|
||||
it('shows the defaults for the all-day state and the selected calendar', () => {
|
||||
renderNew();
|
||||
|
||||
fireEvent.click(screen.getByLabelText('form.all_day_event'));
|
||||
expect(screen.getByText('alerts.by_email(alerts.days_before(1))')).toBeTruthy();
|
||||
expect(screen.queryByText('alerts.minutes_before(15)')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByLabelText('form.all_day_event'));
|
||||
fireEvent.change(screen.getByDisplayValue('Work'), { target: { value: 'cal-2' } });
|
||||
expect(screen.getByText('alerts.by_email(alerts.hours_before(1))')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('copies the defaults into editable rows on Customize and saves them as custom alerts', async () => {
|
||||
const onSave = renderNew();
|
||||
fireEvent.change(screen.getByDisplayValue('Work'), { target: { value: 'cal-2' } });
|
||||
|
||||
fireEvent.click(screen.getByText('alerts.customize'));
|
||||
expect((screen.getByLabelText('alerts.unit') as HTMLSelectElement).value).toBe('hours');
|
||||
expect((screen.getByLabelText('alerts.action') as HTMLSelectElement).value).toBe('email');
|
||||
fireEvent.change(screen.getByLabelText('alerts.amount'), { target: { value: '2' } });
|
||||
|
||||
const data = await savedData(onSave);
|
||||
expect(data.useDefaultAlerts).toBe(false);
|
||||
expect(Object.values(data.alerts!)).toEqual([
|
||||
expect.objectContaining({ action: 'email', trigger: expect.objectContaining({ offset: '-PT2H' }) }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('opens a defaults event in defaults mode and keeps the copy the server stored', async () => {
|
||||
const stored = { k1: { ...offset('-PT30M'), trigger: { '@type': 'OffsetTrigger', offset: '-PT30M' } } } as unknown as Record<string, CalendarEventAlert>;
|
||||
const onSave = renderExisting(makeEvent({ useDefaultAlerts: true, alerts: stored }));
|
||||
|
||||
expect(screen.getByText('alerts.calendar_defaults')).toBeTruthy();
|
||||
expect(screen.getByText('alerts.minutes_before(30)')).toBeTruthy();
|
||||
|
||||
const data = await savedData(onSave);
|
||||
expect(data.useDefaultAlerts).toBe(true);
|
||||
expect(data.alerts).toEqual(stored);
|
||||
});
|
||||
|
||||
it('sends the calendar defaults when a custom event goes back to them', async () => {
|
||||
const onSave = renderExisting(makeEvent());
|
||||
|
||||
fireEvent.click(screen.getByText('alerts.use_defaults'));
|
||||
expect(screen.getByText('alerts.minutes_before(15)')).toBeTruthy();
|
||||
|
||||
const data = await savedData(onSave);
|
||||
expect(data.useDefaultAlerts).toBe(true);
|
||||
expect(data.alerts).toEqual({ w1: offset('-PT15M') });
|
||||
});
|
||||
|
||||
it('sends the new calendar defaults when a defaults event moves to another calendar', async () => {
|
||||
const onSave = renderExisting(makeEvent({ useDefaultAlerts: true, alerts: { k1: offset('-PT15M') } }));
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('Work'), { target: { value: 'cal-2' } });
|
||||
const data = await savedData(onSave);
|
||||
|
||||
expect(data.useDefaultAlerts).toBe(true);
|
||||
expect(data.alerts).toEqual({ h1: offset('-PT1H', 'email') });
|
||||
});
|
||||
|
||||
it('labels the effective alerts of a defaults event in view mode', () => {
|
||||
renderExisting(makeEvent({ useDefaultAlerts: true, alerts: null }), false);
|
||||
expect(screen.getByText('alerts.minutes_before(15)')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Trash2, Plus } from "@/components/icons";
|
||||
import type { CalendarEventAlert } from "@/lib/jmap/types";
|
||||
import {
|
||||
describeAlert,
|
||||
newAlertRow,
|
||||
type AlertAction,
|
||||
type AlertRow,
|
||||
@@ -28,12 +31,35 @@ export function formatAlertRowLabel(row: Omit<AlertRow, "id">, t: CalendarT): st
|
||||
return row.action === "email" ? t("alerts.by_email", { label }) : label;
|
||||
}
|
||||
|
||||
/** Read-only list of an alert map, for alerts that come from calendar defaults. */
|
||||
export function AlertSummary({ alerts }: { alerts: Record<string, CalendarEventAlert> | null }) {
|
||||
const t = useTranslations("calendar");
|
||||
const labels: string[] = [];
|
||||
let other = 0;
|
||||
for (const alert of Object.values(alerts ?? {})) {
|
||||
const row = describeAlert(alert);
|
||||
if (row) labels.push(formatAlertRowLabel(row, t));
|
||||
else other += 1;
|
||||
}
|
||||
if (labels.length === 0 && other === 0) {
|
||||
return <p className="text-sm text-muted-foreground">{t("alerts.none")}</p>;
|
||||
}
|
||||
return (
|
||||
<ul className="text-sm space-y-0.5">
|
||||
{labels.map((label, i) => <li key={i}>{label}</li>)}
|
||||
{other > 0 && <li className="text-muted-foreground">{t("alerts.other", { count: other })}</li>}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
interface AlertListEditorProps {
|
||||
rows: AlertRow[];
|
||||
onChange: (rows: AlertRow[]) => void;
|
||||
/** Alerts the rows can't show (absolute, after the start); saved unchanged. */
|
||||
keptCount?: number;
|
||||
disabled?: boolean;
|
||||
/** Shown next to "Add reminder". */
|
||||
extraAction?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,7 +67,7 @@ interface AlertListEditorProps {
|
||||
* 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) {
|
||||
export function AlertListEditor({ rows, onChange, keptCount = 0, disabled = false, extraAction }: AlertListEditorProps) {
|
||||
const t = useTranslations("calendar");
|
||||
|
||||
const update = (id: string, patch: Partial<Omit<AlertRow, "id">>) => {
|
||||
@@ -113,15 +139,18 @@ export function AlertListEditor({ rows, onChange, keptCount = 0, disabled = fals
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange([...rows, newAlertRow(10, "minutes")])}
|
||||
className="mt-2 inline-flex items-center gap-1 text-sm text-primary hover:underline disabled:opacity-50"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t("alerts.add")}
|
||||
</button>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange([...rows, newAlertRow(10, "minutes")])}
|
||||
className="inline-flex items-center gap-1 text-sm text-primary hover:underline disabled:opacity-50"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t("alerts.add")}
|
||||
</button>
|
||||
{extraAction}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,8 +9,15 @@ import { LinkifiedText } from "@/components/ui/linkified-text";
|
||||
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 { AlertListEditor, AlertSummary, formatAlertRowLabel } from "./alert-list-editor";
|
||||
import {
|
||||
alertsToRows,
|
||||
describeAlert,
|
||||
getCalendarDefaultAlerts,
|
||||
getEffectiveAlerts,
|
||||
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";
|
||||
@@ -119,10 +126,11 @@ function formatDurationDisplay(minutes: number): string {
|
||||
return `${h}h${m}min`;
|
||||
}
|
||||
|
||||
function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
|
||||
if (!event.alerts) return null;
|
||||
function getAlertLabel(event: CalendarEvent, calendars: Calendar[], t: ReturnType<typeof useTranslations>): string | null {
|
||||
const alerts = getEffectiveAlerts(event, calendars);
|
||||
if (!alerts) return null;
|
||||
const labels: string[] = [];
|
||||
for (const alert of Object.values(event.alerts)) {
|
||||
for (const alert of Object.values(alerts)) {
|
||||
const row = describeAlert(alert);
|
||||
if (!row) continue;
|
||||
labels.push(formatAlertRowLabel(row, t));
|
||||
@@ -326,11 +334,38 @@ export function EventModal({
|
||||
() => (customRule ? buildRecurrenceSummary(customRule, t, locale) : null),
|
||||
[customRule, t, locale]
|
||||
);
|
||||
// A new event starts on the calendar's default alerts.
|
||||
const [useDefaultAlerts, setUseDefaultAlerts] = useState(() => (event ? event.useDefaultAlerts === true : true));
|
||||
// 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<Record<string, CalendarEventAlert>>(initialAlerts.preserved);
|
||||
const [initialAlerts] = useState(() =>
|
||||
event && !event.useDefaultAlerts ? alertsToRows(event.alerts) : { rows: [], preserved: {} }
|
||||
);
|
||||
const [preservedAlerts, setPreservedAlerts] = useState<Record<string, CalendarEventAlert>>(initialAlerts.preserved);
|
||||
const [alertRows, setAlertRows] = useState<AlertRow[]>(initialAlerts.rows);
|
||||
|
||||
// What defaults mode stands for. Stalwart copies the calendar's defaults
|
||||
// into an event only when it is created, so an existing event that stays
|
||||
// in its calendar and keeps its all-day state shows the copy it stored;
|
||||
// otherwise the selected calendar's current defaults apply.
|
||||
const defaultAlerts = useMemo(() => {
|
||||
if (
|
||||
event?.useDefaultAlerts &&
|
||||
event.alerts && Object.keys(event.alerts).length > 0 &&
|
||||
getPrimaryCalendarId(event) === calendarId &&
|
||||
!!event.showWithoutTime === allDay
|
||||
) {
|
||||
return event.alerts;
|
||||
}
|
||||
return getCalendarDefaultAlerts(calendars.find((c) => c.id === calendarId), allDay);
|
||||
}, [event, calendars, calendarId, allDay]);
|
||||
|
||||
const customizeAlerts = useCallback(() => {
|
||||
const { rows, preserved } = alertsToRows(defaultAlerts);
|
||||
setAlertRows(rows);
|
||||
setPreservedAlerts(preserved);
|
||||
setUseDefaultAlerts(false);
|
||||
}, [defaultAlerts]);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
// Set synchronously, so a second save in the same tick is refused too.
|
||||
@@ -529,11 +564,20 @@ export function EventModal({
|
||||
if (event.excludedRecurrenceRules) data.excludedRecurrenceRules = 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) {
|
||||
data.alerts = null;
|
||||
if (useDefaultAlerts) {
|
||||
data.useDefaultAlerts = true;
|
||||
// Stalwart copies the calendar's defaults into a new event itself
|
||||
// (sending alerts too would double them), but setting the flag on an
|
||||
// update keeps whatever alerts the event had, so updates carry them.
|
||||
if (event) data.alerts = defaultAlerts ? structuredClone(defaultAlerts) : null;
|
||||
} else {
|
||||
data.useDefaultAlerts = false;
|
||||
const builtAlerts = rowsToAlerts(alertRows, preservedAlerts);
|
||||
if (Object.keys(builtAlerts).length > 0) {
|
||||
data.alerts = builtAlerts;
|
||||
} else if (event && event.alerts && Object.keys(event.alerts).length > 0) {
|
||||
data.alerts = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (effectiveAttendees.length > 0 && currentUserEmails.length > 0) {
|
||||
@@ -610,7 +654,7 @@ export function EventModal({
|
||||
savingRef.current = false;
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, customRule, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, resolveContactName, event, onSave, isSaving, t]);
|
||||
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, customRule, useDefaultAlerts, defaultAlerts, alertRows, preservedAlerts, attendees, sendInvitations, currentUserEmails, existingParticipants, resolveContactName, event, onSave, isSaving, t]);
|
||||
|
||||
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
|
||||
if (!event || !userParticipantId || !onRsvp) return;
|
||||
@@ -785,7 +829,7 @@ export function EventModal({
|
||||
const virtualLoc = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || null : null;
|
||||
const viewParticipants = getParticipantList(event, { resolveName: resolveContactName });
|
||||
const recurrenceLabel = getRecurrenceLabel(event, t, locale);
|
||||
const alertLabel = getAlertLabel(event, t);
|
||||
const alertLabel = getAlertLabel(event, calendars, t);
|
||||
const eventCalendar = calendars.find(c => event.calendarIds[c.id]);
|
||||
const color = getEventColor(event, eventCalendar);
|
||||
|
||||
@@ -1246,11 +1290,34 @@ export function EventModal({
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">{t("alerts.title")}</label>
|
||||
<AlertListEditor
|
||||
rows={alertRows}
|
||||
onChange={setAlertRows}
|
||||
keptCount={Object.keys(preservedAlertsRef.current).length}
|
||||
/>
|
||||
{useDefaultAlerts ? (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1">{t("alerts.calendar_defaults")}</p>
|
||||
<AlertSummary alerts={defaultAlerts} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={customizeAlerts}
|
||||
className="mt-2 inline-flex items-center gap-1 text-sm text-primary hover:underline"
|
||||
>
|
||||
{t("alerts.customize")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<AlertListEditor
|
||||
rows={alertRows}
|
||||
onChange={setAlertRows}
|
||||
keptCount={Object.keys(preservedAlerts).length}
|
||||
extraAction={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUseDefaultAlerts(true)}
|
||||
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
|
||||
>
|
||||
{t("alerts.use_defaults")}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{attendees.length > 0 && (
|
||||
|
||||
@@ -230,6 +230,13 @@ describe('getEffectiveAlerts', () => {
|
||||
expect(getEffectiveAlerts(event, calendars)).toBe(defaultAlerts);
|
||||
});
|
||||
|
||||
it('prefers the alerts the server copied into a defaults event over the current calendar defaults', () => {
|
||||
const copied = { k1: makeAlert({ action: 'email' }) };
|
||||
const event = makeEvent({ useDefaultAlerts: true, alerts: copied });
|
||||
const calendars = [makeCalendar({ defaultAlertsWithTime: { d1: makeAlert() } })];
|
||||
expect(getEffectiveAlerts(event, calendars)).toBe(copied);
|
||||
});
|
||||
|
||||
it('returns null when calendar not found', () => {
|
||||
const event = makeEvent({ useDefaultAlerts: true, calendarIds: { 'missing': true } });
|
||||
const calendars = [makeCalendar()];
|
||||
|
||||
@@ -76,6 +76,12 @@ export function getEffectiveAlerts(
|
||||
if (!event.useDefaultAlerts) {
|
||||
return event.alerts;
|
||||
}
|
||||
// Stalwart copies the calendar's defaults into the event when it is
|
||||
// created and later default changes don't reach it. Its copy is what the
|
||||
// server (email alerts) and other clients fire, so follow it when present.
|
||||
if (event.alerts && Object.keys(event.alerts).length > 0) {
|
||||
return event.alerts;
|
||||
}
|
||||
|
||||
if (!event.calendarIds) return null;
|
||||
const calendarId = Object.keys(event.calendarIds)[0];
|
||||
|
||||
@@ -3248,7 +3248,11 @@
|
||||
"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}}"
|
||||
"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}}",
|
||||
"other": "{count, plural, one {# other reminder} other {# other reminders}}",
|
||||
"calendar_defaults": "Calendar defaults",
|
||||
"customize": "Customize",
|
||||
"use_defaults": "Use calendar defaults"
|
||||
},
|
||||
"settings": {
|
||||
"limit_hours": "Limit visible hours",
|
||||
|
||||
Reference in New Issue
Block a user