diff --git a/components/boot-skeleton/mail-skeleton.tsx b/components/boot-skeleton/mail-skeleton.tsx index e94dd4f89..063d040ba 100644 --- a/components/boot-skeleton/mail-skeleton.tsx +++ b/components/boot-skeleton/mail-skeleton.tsx @@ -404,9 +404,10 @@ function MobileHeaderSkeleton() { * The reader's top toolbar while a message loads. The loaded reader lifts its * toolbar above the message row on a tablet or desktop (EmailViewer's * `liftToolbar`); its loading state draws this there too, so the row below - * doesn't move when the message arrives. + * doesn't move when the message arrives. `end` is a real control at the end + * of the row (the reader's plugin panel button), where the loaded toolbar has it. */ -export function MailMessageToolbarSkeleton() { +export function MailMessageToolbarSkeleton({ end }: { end?: ReactNode } = {}) { const button = "h-8 w-20 rounded-md"; return (
@@ -438,6 +439,7 @@ export function MailMessageToolbarSkeleton() { + {end}
diff --git a/components/calendar/__tests__/calendar-side-panel.test.tsx b/components/calendar/__tests__/calendar-side-panel.test.tsx new file mode 100644 index 000000000..51b061ca5 --- /dev/null +++ b/components/calendar/__tests__/calendar-side-panel.test.tsx @@ -0,0 +1,153 @@ +import { act, render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { Calendar, CalendarEvent } from '@/lib/jmap/types'; +import { useCalendarStore } from '@/stores/calendar-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { useToastStore } from '@/stores/toast-store'; +import { useTaskStore } from '@/stores/task-store'; +import { clearSlotOffers, framePropsOf, offerSlots } from '@/components/plugins/__tests__/fake-slot-offer'; + +// The plugin side panel (app-side-panel) in Calendar: its button at the end +// of the toolbar row, the panel last in the row under it, and the event the +// user opened in its context. + +vi.mock('@/components/plugins/plugin-iframe-slot', () => import('@/components/plugins/__tests__/fake-slot-offer')); +const media = vi.hoisted(() => ({ mobile: false, desktop: true })); +vi.mock('@/hooks/use-media-query', () => ({ + useIsMobile: () => media.mobile, + useIsDesktop: () => media.desktop, + useIsTablet: () => !media.mobile && !media.desktop, + useMediaQuery: () => false, +})); +vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null })); +vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null })); +vi.mock('@/components/layout/inline-app-view', () => ({ InlineAppView: () => null })); +vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null })); +vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null })); +vi.mock('@/hooks/use-refresh-gesture', () => ({ useRefreshGesture: () => ({ indicator: null }) })); +vi.mock('@/hooks/use-calendar-push-refresh', () => ({ useCalendarPushRefresh: () => {} })); +vi.mock('@/hooks/use-account-principal-emails', () => ({ useAccountPrincipalEmails: () => [] })); +vi.mock('../participant-availability', () => ({ ParticipantAvailability: () => null })); +const config = vi.hoisted(() => ({ demoMode: false })); +vi.mock('@/hooks/use-config', () => ({ useConfig: () => config })); +const lite = vi.hoisted(() => ({ on: false })); +vi.mock('@/lib/lite', async (importOriginal) => ({ + ...(await importOriginal()), + get IS_LITE() { return lite.on; }, +})); + + +import { CalendarApp } from '../calendar-app'; + +vi.setConfig({ testTimeout: 30000 }); + +const RIGHTS = { + mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, + mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true, +}; + +const calendars = [{ + id: 'cal', name: 'Personal', color: '#123456', isDefault: true, sortOrder: 0, description: null, + isSubscribed: true, isVisible: true, includeInAvailability: 'all', defaultAlertsWithTime: null, + defaultAlertsWithoutTime: null, timeZone: null, shareWith: null, myRights: RIGHTS, +}] as unknown as Calendar[]; + +function makeEvent(overrides: Partial = {}): CalendarEvent { + return { + id: 'e1', uid: 'u1', '@type': 'Event', title: 'Standup', description: '', start: '2026-10-12T10:00:00', + duration: 'PT1H', timeZone: null, showWithoutTime: false, calendarIds: { cal: true }, + participants: null, alerts: null, recurrenceRules: null, isOrigin: true, status: 'confirmed', + ...overrides, + } as unknown as CalendarEvent; +} + +const client = { + getAccountId: () => 'acc', + getMaxParticipantsPerEvent: () => null, + supportsCalendars: () => true, + queryCalendarEvents: vi.fn().mockResolvedValue([]), + getCalendarEvent: vi.fn().mockResolvedValue(null), + getCalendarTasks: vi.fn().mockResolvedValue([]), +}; + +const store = { + fetchCalendars: vi.fn(), + fetchEvents: vi.fn(), + refreshAllSubscriptions: vi.fn(), + createEvent: vi.fn(), + updateEvent: vi.fn(), + deleteEvent: vi.fn(), + rsvpEvent: vi.fn(), +}; + +function setUp(events: CalendarEvent[] = [makeEvent()], viewMode: 'month' | 'week' | 'day' | 'agenda' | 'tasks' = 'week') { + for (const fn of Object.values(store)) fn.mockReset(); + store.createEvent.mockImplementation(async (_c: unknown, data: Partial) => ({ ...makeEvent(), ...data, id: 'new' })); + store.updateEvent.mockResolvedValue(true); + store.deleteEvent.mockResolvedValue(true); + useAuthStore.setState({ isAuthenticated: true, client: client as never, isLoading: false, activeAccountId: 'acc' }); + useCalendarStore.setState({ + calendars, events, supportsCalendar: true, selectedCalendarIds: ['cal'], viewMode, + selectedDate: new Date(2026, 9, 12), isLoading: false, isLoadingEvents: false, error: null, + ...store, + } as never); + useSettingsStore.setState({ calendarHoverPreview: 'instant', enableCalendarTasks: true, proInterface: false } as never); + useToastStore.setState({ toasts: [] } as never); + useTaskStore.setState({ tasks: [] } as never); + client.getCalendarTasks.mockResolvedValue([]); +} + + +beforeEach(() => { + media.mobile = false; + media.desktop = true; + localStorage.clear(); + setUp(); +}); +afterEach(() => clearSlotOffers()); + +const frame = () => screen.queryByTestId('plugin-frame-app-side-panel'); + +describe('Calendar side panel', () => { + it('has no button and no panel without a plugin', () => { + render(); + expect(document.querySelector('[data-plugin-panel-toggle]')).toBeNull(); + expect(frame()).toBeNull(); + }); + + it('puts the button at the end of the toolbar row and the panel under it, with the open event', async () => { + offerSlots('p1', ['app-side-panel']); + render(); + const toggle = document.querySelector('[data-plugin-panel-toggle]')!; + const row = screen.getByTestId('calendar-view-menu').closest('.h-14')!; + // The last control of the row: after the view menu. + const buttons = Array.from(row.querySelectorAll('button')); + expect(buttons[buttons.length - 1]).toBe(toggle); + expect(toggle).toHaveAccessibleName('plugin_panel'); + expect(toggle).toHaveAttribute('aria-pressed', 'true'); + + // Open by default; the last thing in the row below the toolbar. + const panel = document.querySelector('[data-app-side-panel]')!; + expect(panel.parentElement!.lastElementChild).toBe(panel); + expect(panel.parentElement!.getAttribute('data-tour')).toBe('calendar-view'); + expect(framePropsOf(frame()!)).toEqual({ view: 'calendar', context: {}, focusRequest: 0 }); + + fireEvent.click(screen.getAllByRole('button', { name: /Standup/ })[0]); + await screen.findByRole('dialog', { name: 'Standup' }); + await waitFor(() => expect((framePropsOf(frame()!).context as { event?: { title: string } }).event?.title).toBe('Standup')); + + fireEvent.click(toggle); + expect(frame()).toBeNull(); + expect(toggle).toHaveAttribute('aria-pressed', 'false'); + }); + + it('has no panel on a phone', () => { + media.mobile = true; + media.desktop = false; + offerSlots('p1', ['app-side-panel']); + act(() => { render(); }); + expect(document.querySelector('[data-plugin-panel-toggle]')).toBeNull(); + expect(frame()).toBeNull(); + }); +}); diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 6b6595315..1bccba921 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -118,6 +118,7 @@ import { MvTopBar } from "@/components/layout/mv-top-bar"; import { useMountainViewShell } from "@/hooks/use-mountain-view-shell"; import { calendarHooks } from "@/lib/plugin-hooks"; import { eventToView } from "@/lib/plugin-projection"; +import { useAppSidePanel } from "@/components/plugins/app-side-panel"; import { AppMain, SkipLink } from "@/components/layout/app-landmarks"; import { calendarShortcutAction } from "@/lib/calendar-shortcuts"; import { isAppShortcutBlocked, isArrowKeyWidgetTarget } from "@/lib/keyboard"; @@ -1763,6 +1764,11 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = // event gets the primary ring). const selectedEventId = detailPinned ? detailEvent?.id ?? null : showEventModal ? editEvent?.id ?? null : null; + // The plugin side panel (app-side-panel), with the event the user opened. + const openEvent = detailPinned ? detailEvent : showEventModal ? editEvent : null; + const sidePanelContext = useMemo(() => (openEvent ? { event: eventToView(openEvent) } : {}), [openEvent]); + const sidePanel = useAppSidePanel({ view: "calendar", context: sidePanelContext }); + const visibleEvents = useMemo(() => { const filtered = events.filter((e) => { if (!e.start || !e.calendarIds) return false; @@ -2142,6 +2148,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = /> ) : null} onCloseSearch={leaveSearch} + panelToggle={sidePanel.toggle} /> ); @@ -2321,6 +2328,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = {/* isolate keeps the views' sticky headers (z-50) below the toolbar's import dropdown, which overlaps this area (#1049). */}
)} + + {sidePanel.panel}
diff --git a/components/calendar/calendar-toolbar.tsx b/components/calendar/calendar-toolbar.tsx index f67c49bb3..75fb07763 100644 --- a/components/calendar/calendar-toolbar.tsx +++ b/components/calendar/calendar-toolbar.tsx @@ -61,6 +61,8 @@ interface CalendarToolbarProps { mobileSearchField?: ReactNode; /** Phones: closes the search row. */ onCloseSearch?: () => void; + /** The plugin side panel's button, at the very end of the row (desktop and tablet). */ + panelToggle?: ReactNode; } // Shortcut keys handled by the calendar app (see its keydown handler). @@ -101,6 +103,7 @@ export function CalendarToolbar({ onOpenSearch, mobileSearchField, onCloseSearch, + panelToggle, }: CalendarToolbarProps) { const t = useTranslations("calendar"); const paint = useCvdColor(); @@ -484,6 +487,9 @@ export function CalendarToolbar({ ))} + + {/* The row is px-3; the margin puts the button where the reader has it (px-4, lg:px-6). */} + {panelToggle &&
{panelToggle}
} ); } diff --git a/components/contacts/__tests__/contacts-side-panel.test.tsx b/components/contacts/__tests__/contacts-side-panel.test.tsx new file mode 100644 index 000000000..8de93810d --- /dev/null +++ b/components/contacts/__tests__/contacts-side-panel.test.tsx @@ -0,0 +1,132 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { ContactCard } from '@/lib/jmap/types'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import { clearSlotOffers, framePropsOf, offerSlots } from '@/components/plugins/__tests__/fake-slot-offer'; +import { setActionDispatcher } from '@/lib/plugin-sandbox/actions'; + +// The plugin side panel (app-side-panel) in Contacts: a row of its own over +// the contact and the panel, with the open contact's name and the button at +// its end, and the contact in the panel's context. + +vi.mock('@/components/plugins/plugin-iframe-slot', () => import('@/components/plugins/__tests__/fake-slot-offer')); +const media = vi.hoisted(() => ({ mobile: false })); +vi.mock('@/hooks/use-media-query', async (importOriginal) => ({ + ...(await importOriginal()), + useIsMobile: () => media.mobile, +})); +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: vi.fn(), back: vi.fn() }), + useParams: () => ({ locale: 'en' }), + usePathname: () => '/en/contacts', + useSearchParams: () => new URLSearchParams(), +})); +vi.mock('@/components/contacts/contact-activity', () => ({ ContactActivity: () => null })); +vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () =>