feat: let plugins add sections to event details and the event form, and label calendar days

This commit is contained in:
Linus Rath
2026-10-14 04:43:40 +02:00
parent 1ba53655e5
commit d144a6e85c
9 changed files with 355 additions and 1 deletions
@@ -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(
<EventDetailPopover
event={existing}
calendar={calendars[0]}
anchorRect={new DOMRect(100, 100, 50, 20)}
onEdit={vi.fn()}
onDelete={vi.fn()}
onDuplicate={vi.fn()}
onClose={vi.fn()}
onSaveNote={vi.fn()}
/>,
);
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(<EventModal event={existing} calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
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(<EventModal event={existing} calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
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(<EventModal event={existing} calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
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(
<EventModal
calendars={calendars}
defaultDate={new Date('2026-10-12T10:00:00')}
defaultEndDate={new Date('2026-10-12T11:30:00')}
defaultCalendarId="cal-1"
onSave={vi.fn()}
onClose={vi.fn()}
/>,
);
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(<EventModal calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
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<HTMLElement>(`[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(<CalendarWeekView {...week} />);
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<HTMLElement>('[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(<CalendarDayView {...week} windowKey="day:2026-09-09" rangeStart={new Date(2026, 8, 9)} rangeEnd={new Date(2026, 8, 9)} />);
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(<CalendarMonthView {...month} />);
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(<CalendarMonthView {...month} isMobile />);
expect(document.querySelector('[data-plugin-surface="calendar-day-header"]')).toBeNull();
});
it('marks days with a dot in the small month calendar', () => {
render(
<MiniCalendar selectedDate={new Date(2026, 8, 1)} displayMonth={new Date(2026, 8, 1)} onSelectDate={vi.fn()} onChangeMonth={vi.fn()} />,
);
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');
});
});
@@ -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")}
</div>
<PluginDayLabel surface="calendar-day-header" date={key} className="w-full" />
</div>
{hasAllDayArea && (
@@ -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)}
</button>
{!isMobile && <PluginDayLabel surface="calendar-day-header" date={key} className="min-w-0 flex-1" />}
</div>
{isMobile && !showChips ? (
<div className="flex items-center justify-center gap-0.5 flex-wrap">
@@ -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")}
</button>
<PluginDayLabel surface="calendar-day-header" date={dayKey(day)} className="w-full px-1 text-center" />
</div>
);
})}
@@ -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({
</p>
</div>
)}
<PluginSlot name="calendar-event-detail" className="w-full" extraProps={pluginProps} />
</div>
{/* Quick Note */}
+56 -1
View File
@@ -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<CalendarEventView>(() => {
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({
</div>
</div>
)}
{eventDetailProps && <PluginSlot name="calendar-event-detail" className="w-full" extraProps={eventDetailProps} />}
</div>
</div>
@@ -1000,6 +1051,8 @@ export function EventModal({
</p>
</div>
)}
{eventDetailProps && <PluginSlot name="calendar-event-detail" className="w-full" extraProps={eventDetailProps} />}
</div>
</div>
@@ -1347,6 +1400,8 @@ export function EventModal({
</label>
</div>
)}
<PluginSlot name="calendar-event-form" className="w-full" extraProps={eventFormProps} />
</div>
</div>
+2
View File
@@ -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 && (
<span className="absolute bottom-0.5 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-primary" />
)}
<PluginDayLabel surface="mini-calendar-marks" date={format(day, "yyyy-MM-dd")} />
</button>
</Fragment>
);
+63
View File
@@ -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<string, RegisteredDayLabel[]>();
/** 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 (
<>
<span
aria-hidden="true"
data-plugin-surface="mini-calendar-marks"
title={tooltip(marks)}
className={cn("pointer-events-none absolute top-0.5 end-0.5 h-1 w-1 rounded-full bg-current opacity-70", className)}
/>
<span className="sr-only">{marks.map((l) => l.title ?? l.text ?? l.pluginName).join(", ")}</span>
</>
);
}
const withText = labels.filter((l) => l.text);
if (withText.length === 0) return null;
return (
<div
data-plugin-surface="calendar-day-header"
title={tooltip(withText)}
className={cn("max-w-full truncate text-[10px] leading-tight text-muted-foreground", className)}
>
{withText.map((l) => l.text).join(" · ")}
</div>
);
}
@@ -0,0 +1,37 @@
// Test helpers for mount-site tests: a plugin that offers a slot, and a
// stand-in for the sandboxed iframe that shows which props it was handed.
//
// Use with:
// vi.mock('@/components/plugins/plugin-iframe-slot', () => import('@/components/plugins/__tests__/fake-slot-offer'));
import { deregister, register, type ActivePlugin } from '@/lib/plugin-sandbox/registry';
import type { SlotName } from '@/lib/plugin-types';
const registered = new Set<string>();
/** Registers an active plugin that offers these slots. */
export function offerSlots(pluginId: string, slots: SlotName[]): void {
register({
plugin: { id: pluginId, name: pluginId },
code: '',
tier: 'untrusted',
background: {},
slotOffers: slots.map((name) => ({ name, order: 100, hasShouldShow: false })),
hookDisposables: [],
} as unknown as ActivePlugin);
registered.add(pluginId);
}
export function clearSlotOffers(): void {
for (const id of registered) deregister(id);
registered.clear();
}
/** Drop-in for PluginIframeSlot: no iframe, the props as JSON. */
export function PluginIframeSlot({ pluginId, slot, extraProps }: { pluginId: string; slot: string; extraProps?: Record<string, unknown> }) {
return <div data-testid={`plugin-frame-${slot}`} data-plugin-id={pluginId} data-props={JSON.stringify(extraProps ?? {})} />;
}
/** The props the fake iframe for this slot received. */
export function framePropsOf(el: HTMLElement): Record<string, unknown> {
return JSON.parse(el.getAttribute('data-props') || '{}') as Record<string, unknown>;
}