From d144a6e85ccb143f7cd0f87462d676468d83e79a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 04:43:40 +0200 Subject: [PATCH] feat: let plugins add sections to event details and the event form, and label calendar days --- .../calendar-plugin-surfaces.test.tsx | 186 ++++++++++++++++++ components/calendar/calendar-day-view.tsx | 2 + components/calendar/calendar-month-view.tsx | 2 + components/calendar/calendar-week-view.tsx | 2 + components/calendar/event-detail-popover.tsx | 5 + components/calendar/event-modal.tsx | 57 +++++- components/calendar/mini-calendar.tsx | 2 + components/calendar/plugin-day-label.tsx | 63 ++++++ .../plugins/__tests__/fake-slot-offer.tsx | 37 ++++ 9 files changed, 355 insertions(+), 1 deletion(-) create mode 100644 components/calendar/__tests__/calendar-plugin-surfaces.test.tsx create mode 100644 components/calendar/plugin-day-label.tsx create mode 100644 components/plugins/__tests__/fake-slot-offer.tsx diff --git a/components/calendar/__tests__/calendar-plugin-surfaces.test.tsx b/components/calendar/__tests__/calendar-plugin-surfaces.test.tsx new file mode 100644 index 000000000..06e07cf7d --- /dev/null +++ b/components/calendar/__tests__/calendar-plugin-surfaces.test.tsx @@ -0,0 +1,186 @@ +import { render, screen, fireEvent, waitFor, act, cleanup } from '@testing-library/react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { EventModal } from '../event-modal'; +import { EventDetailPopover } from '../event-detail-popover'; +import { CalendarWeekView } from '../calendar-week-view'; +import { CalendarDayView } from '../calendar-day-view'; +import { CalendarMonthView } from '../calendar-month-view'; +import { MiniCalendar } from '../mini-calendar'; +import { resetDayLabels, setDayLabels } from '@/lib/plugin-sandbox/day-labels'; +import { clearSlotOffers, framePropsOf, offerSlots } from '@/components/plugins/__tests__/fake-slot-offer'; +import type { Calendar, CalendarEvent } from '@/lib/jmap/types'; + +// Plugin surfaces in the calendar: a section in an event's details and in +// its form, and the labels and dots plugins put on days. + +vi.mock('@/components/plugins/plugin-iframe-slot', () => import('@/components/plugins/__tests__/fake-slot-offer')); + +const calendars = [ + { id: 'cal-1', name: 'Work', color: '#123456', isDefault: true, myRights: { mayWriteAll: true, mayWriteOwn: true } }, +] as unknown as Calendar[]; + +const existing = { + id: 'e1', + uid: 'uid-1', + title: 'Planning', + start: '2026-10-12T10:00:00', + duration: 'PT1H', + timeZone: null, + showWithoutTime: false, + calendarIds: { 'cal-1': true }, +} as unknown as CalendarEvent; + +afterEach(() => { + cleanup(); + clearSlotOffers(); + resetDayLabels(); +}); + +describe('calendar-event-detail', () => { + it('shows the event in the detail popover', () => { + offerSlots('p1', ['calendar-event-detail']); + render( + , + ); + + const frame = screen.getByTestId('plugin-frame-calendar-event-detail'); + expect(framePropsOf(frame).event).toMatchObject({ id: 'e1', uid: 'uid-1', title: 'Planning', calendarId: 'cal-1' }); + }); + + it('shows the event in the modal view, and not in the form', () => { + offerSlots('p1', ['calendar-event-detail']); + render(); + + expect(framePropsOf(screen.getByTestId('plugin-frame-calendar-event-detail')).event).toMatchObject({ id: 'e1', title: 'Planning' }); + + fireEvent.click(screen.getByText('events.edit')); + expect(screen.queryByTestId('plugin-frame-calendar-event-detail')).toBeNull(); + }); + + it('renders nothing when no plugin offers it', () => { + render(); + expect(document.querySelector('[data-plugin-slot]')).toBeNull(); + }); +}); + +describe('calendar-event-form', () => { + it('hands the plugin the form state of an edited event and follows the fields', async () => { + offerSlots('p1', ['calendar-event-form']); + render(); + expect(screen.queryByTestId('plugin-frame-calendar-event-form')).toBeNull(); + + fireEvent.click(screen.getByText('events.edit')); + const frame = screen.getByTestId('plugin-frame-calendar-event-form'); + expect(framePropsOf(frame).event).toMatchObject({ + id: 'e1', uid: 'uid-1', calendarId: 'cal-1', title: 'Planning', start: '2026-10-12T10:00:00', duration: 'PT1H', isAllDay: false, + }); + + fireEvent.change(screen.getByPlaceholderText('form.title'), { target: { value: 'Planning v2' } }); + await waitFor(() => { + expect(framePropsOf(screen.getByTestId('plugin-frame-calendar-event-form')).event).toMatchObject({ title: 'Planning v2' }); + }); + }); + + it('gives a new event an empty id', () => { + offerSlots('p1', ['calendar-event-form']); + render( + , + ); + + expect(framePropsOf(screen.getByTestId('plugin-frame-calendar-event-form')).event).toMatchObject({ + id: '', uid: '', calendarId: 'cal-1', duration: 'PT1H30M', participants: [], + }); + }); + + it('renders nothing when no plugin offers it', () => { + render(); + expect(document.querySelector('[data-plugin-slot]')).toBeNull(); + }); +}); + +describe('calendar-day-header and mini-calendar-marks', () => { + const week = { + focus: { date: new Date(2026, 8, 9), nonce: 0 }, + windowKey: 'week:2026-09-07', + rangeStart: new Date(2026, 8, 7), + rangeEnd: new Date(2026, 8, 13), + selectedDate: new Date(2026, 8, 9), + events: [], + calendars, + onSelectDate: vi.fn(), + onSelectEvent: vi.fn(), + onCreateAtTime: vi.fn(), + }; + + function headerOf(dayKey: string): HTMLElement { + const header = document.querySelector(`[role="columnheader"][data-day="${dayKey}"], [data-day="${dayKey}"] [role="columnheader"]`); + if (!header) throw new Error(`no header for ${dayKey}`); + return header; + } + + it('puts a label under the day name in the week view', () => { + render(); + expect(document.querySelector('[data-plugin-surface="calendar-day-header"]')).toBeNull(); + + act(() => setDayLabels('hol', 'Holidays', [{ date: '2026-09-09', text: 'Feast day', title: 'A feast' }])); + + const label = headerOf('2026-09-09').querySelector('[data-plugin-surface="calendar-day-header"]'); + expect(label).toHaveTextContent('Feast day'); + expect(label?.title).toBe('A feast (Holidays)'); + expect(headerOf('2026-09-10').querySelector('[data-plugin-surface="calendar-day-header"]')).toBeNull(); + }); + + it('puts a label under the day name in the day view', () => { + act(() => setDayLabels('hol', 'Holidays', [{ date: '2026-09-09', text: 'Feast day' }])); + render(); + + expect(headerOf('2026-09-09').querySelector('[data-plugin-surface="calendar-day-header"]')).toHaveTextContent('Feast day'); + }); + + it('shows the label in the month cell header on desktop only', () => { + act(() => setDayLabels('hol', 'Holidays', [{ date: '2026-09-09', text: 'Feast day' }])); + const month = { ...week, windowKey: 'month:2026-09-09', rangeStart: new Date(2026, 7, 31), rangeEnd: new Date(2026, 9, 4) }; + const { unmount } = render(); + + const labels = document.querySelectorAll('[data-plugin-surface="calendar-day-header"]'); + expect(labels).toHaveLength(1); + expect(labels[0].closest('[role="gridcell"]')?.getAttribute('aria-label')).toMatch(/9/); + unmount(); + + render(); + expect(document.querySelector('[data-plugin-surface="calendar-day-header"]')).toBeNull(); + }); + + it('marks days with a dot in the small month calendar', () => { + render( + , + ); + expect(document.querySelector('[data-plugin-surface="mini-calendar-marks"]')).toBeNull(); + + act(() => setDayLabels('hol', 'Holidays', [ + { date: '2026-09-09', dot: true, title: 'A feast' }, + { date: '2026-09-10', text: 'Text only' }, + ])); + + const dots = document.querySelectorAll('[data-plugin-surface="mini-calendar-marks"]'); + expect(dots).toHaveLength(1); + expect(dots[0].closest('button')).toHaveTextContent('9'); + expect(dots[0].closest('button')).toHaveTextContent('A feast'); + }); +}); diff --git a/components/calendar/calendar-day-view.tsx b/components/calendar/calendar-day-view.tsx index a111549e5..da1a8e942 100644 --- a/components/calendar/calendar-day-view.tsx +++ b/components/calendar/calendar-day-view.tsx @@ -20,6 +20,7 @@ import { useScrollWindow, getScrollStart, setScrollStart, scrollToStart } from " import { dayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window"; import type { PendingEventPreview } from "./event-modal"; import { readableTextOn } from "@/lib/color-transform"; +import { PluginDayLabel } from "./plugin-day-label"; interface CalendarDayViewProps extends ScrollWindowViewProps { selectedDate: Date; @@ -334,6 +335,7 @@ export function CalendarDayView({ > {format(day, "d")} + {hasAllDayArea && ( diff --git a/components/calendar/calendar-month-view.tsx b/components/calendar/calendar-month-view.tsx index 31ba69a59..1164876b5 100644 --- a/components/calendar/calendar-month-view.tsx +++ b/components/calendar/calendar-month-view.tsx @@ -18,6 +18,7 @@ import { toast } from "@/stores/toast-store"; import { useCalendarLocale } from "@/hooks/use-calendar-locale"; import { useScrollWindow } from "@/hooks/use-scroll-window"; import { dayKey, parseDayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window"; +import { PluginDayLabel } from "./plugin-day-label"; interface CalendarMonthViewProps extends ScrollWindowViewProps { selectedDate: Date; @@ -379,6 +380,7 @@ export function CalendarMonthView({ > {formatDayNumber(day)} + {!isMobile && } {isMobile && !showChips ? (
diff --git a/components/calendar/calendar-week-view.tsx b/components/calendar/calendar-week-view.tsx index 2a342a034..d7f63c9d4 100644 --- a/components/calendar/calendar-week-view.tsx +++ b/components/calendar/calendar-week-view.tsx @@ -23,6 +23,7 @@ import { useScrollWindow, getScrollStart, setScrollStart, scrollToStart } from " import { dayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window"; import type { PendingEventPreview } from "./event-modal"; import { readableTextOn } from "@/lib/color-transform"; +import { PluginDayLabel } from "./plugin-day-label"; interface CalendarWeekViewProps extends ScrollWindowViewProps { selectedDate: Date; @@ -436,6 +437,7 @@ export function CalendarWeekView({ > {format(day, "d")} +
); })} diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx index e7c573d4c..7a54d8f66 100644 --- a/components/calendar/event-detail-popover.tsx +++ b/components/calendar/event-detail-popover.tsx @@ -25,6 +25,8 @@ import { canUserRsvp, getEventEditability, type EditabilityContext } from "@/lib import { useFormatEventDate } from "@/hooks/use-format-event-date"; import { useContactNameResolver } from "@/hooks/use-contact-name-resolver"; import { findMeetingLink, locationAction, mapsUrl, primaryLocationName } from "@/lib/event-links"; +import { PluginSlot } from "@/components/plugins/plugin-slot"; +import { eventToView } from "@/lib/plugin-projection"; interface EventDetailPopoverProps { event: CalendarEvent; @@ -145,6 +147,7 @@ export function EventDetailPopover({ // Invitations (Teams above all) leave virtualLocations empty and bury the // join URL in the description; surface it as the meeting link anyway. const meeting = useMemo(() => findMeetingLink(event), [event]); + const pluginProps = useMemo(() => ({ event: eventToView(event) }), [event]); const virtualLocation = meeting?.uri ?? null; // A URL opens as a link, a location that only names the service joins the @@ -580,6 +583,8 @@ export function EventDetailPopover({

)} + + {/* Quick Note */} diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 14b2d26aa..6a0b5b4f3 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -39,7 +39,7 @@ import { calendarHooks, type CalendarEventFormSavePatch, } from "@/lib/plugin-hooks"; -import type { ConflictWarning } from "@/lib/plugin-types"; +import type { CalendarEventView, ConflictWarning } from "@/lib/plugin-types"; import { eventToView } from "@/lib/plugin-projection"; export interface PendingEventPreview { @@ -446,6 +446,55 @@ export function EventModal({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [mode]); + // calendar-event-detail: the event as plugins see it, in the read views. + const eventDetailProps = useMemo(() => (event ? { event: eventToView(event) } : null), [event]); + + // calendar-event-form: the form's current state in the same shape. Typing + // would hand the plugin iframe new props on every key, so the view settles + // for half a second first. + const formEventView = useMemo(() => { + const startStr = allDay ? `${startDate}T00:00:00` : `${startDate}T${startTime || "00:00"}:00`; + const start = new Date(startStr); + let duration = event?.duration ?? "PT1H"; + if (!isNaN(start.getTime())) { + if (allDay) { + const inclusiveEnd = new Date(`${endDate}T00:00:00`); + if (!isNaN(inclusiveEnd.getTime())) duration = buildAllDayDuration(start, inclusiveEnd < start ? start : inclusiveEnd); + } else { + const end = new Date(`${endDate}T${endTime || "00:00"}:00`); + if (!isNaN(end.getTime())) duration = buildDuration(start, end); + } + } + const base = event ? eventToView(event) : null; + return { + id: event?.id ?? "", + uid: event?.uid ?? "", + calendarId, + title, + description, + start: startStr, + duration, + timeZone: getEffectiveTimeZone(), + utcStart: null, + utcEnd: null, + isAllDay: allDay, + location, + status: base?.status ?? "confirmed", + participants: attendees.map((a) => { + const known = base?.participants.find((p) => p.email.toLowerCase() === a.email.toLowerCase()); + return known ?? { name: a.name, email: a.email, role: "attendee", status: "needs-action" }; + }), + isRecurring: recurrence !== "none", + keywords: base?.keywords ?? [], + }; + }, [event, calendarId, title, description, startDate, startTime, endDate, endTime, allDay, location, attendees, recurrence]); + const [settledFormEvent, setSettledFormEvent] = useState(formEventView); + useEffect(() => { + const timer = setTimeout(() => setSettledFormEvent(formEventView), 500); + return () => clearTimeout(timer); + }, [formEventView]); + const eventFormProps = useMemo(() => ({ event: settledFormEvent }), [settledFormEvent]); + // Report live preview to parent for grid outline useEffect(() => { if (!onPreviewChange || isEdit) return; @@ -823,6 +872,8 @@ export function EventModal({ )} + + {eventDetailProps && } @@ -1000,6 +1051,8 @@ export function EventModal({

)} + + {eventDetailProps && } @@ -1347,6 +1400,8 @@ export function EventModal({ )} + + diff --git a/components/calendar/mini-calendar.tsx b/components/calendar/mini-calendar.tsx index 03bc55dc8..cd7a9e8bc 100644 --- a/components/calendar/mini-calendar.tsx +++ b/components/calendar/mini-calendar.tsx @@ -12,6 +12,7 @@ import { getEventDayBounds } from "@/lib/calendar-utils"; import { displayNow } from "@/lib/timezone"; import type { CalendarEvent } from "@/lib/jmap/types"; import { useCalendarLocale } from "@/hooks/use-calendar-locale"; +import { PluginDayLabel } from "./plugin-day-label"; type PickerView = "days" | "months" | "years"; @@ -199,6 +200,7 @@ export function MiniCalendar({ {hasEvent && !selected && !today && ( )} + ); diff --git a/components/calendar/plugin-day-label.tsx b/components/calendar/plugin-day-label.tsx new file mode 100644 index 000000000..63ff9aa0f --- /dev/null +++ b/components/calendar/plugin-day-label.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { useSyncExternalStore } from "react"; +import { cn } from "@/lib/utils"; +import { dayLabelsByDate, subscribeDayLabels, type RegisteredDayLabel } from "@/lib/plugin-sandbox/day-labels"; + +const NONE: RegisteredDayLabel[] = []; +const EMPTY_MAP = new Map(); + +/** The labels plugins put on one day (api.ui.setDayLabels). */ +export function useDayLabels(date: string): RegisteredDayLabel[] { + const byDate = useSyncExternalStore(subscribeDayLabels, dayLabelsByDate, () => EMPTY_MAP); + return byDate.get(date) ?? NONE; +} + +function tooltip(labels: RegisteredDayLabel[]): string { + return labels.map((l) => `${l.title ?? l.text ?? ""} (${l.pluginName})`.trim()).join("\n"); +} + +interface PluginDayLabelProps { + /** + * calendar-day-header: the label text under a day's name (day, week and + * month views). mini-calendar-marks: a dot on the day in the small month + * calendar; the parent must be positioned. + */ + surface: "calendar-day-header" | "mini-calendar-marks"; + /** YYYY-MM-DD */ + date: string; + className?: string; +} + +/** What plugins put on a calendar day. Renders nothing without labels. */ +export function PluginDayLabel({ surface, date, className }: PluginDayLabelProps) { + const labels = useDayLabels(date); + + if (surface === "mini-calendar-marks") { + const marks = labels.filter((l) => l.dot); + if (marks.length === 0) return null; + return ( + <> +