mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 00:14:23 +00:00
feat: let plugins add sections to event details and the event form, and label calendar days
This commit is contained in:
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user