From 8fcd64721549203bc62053a887c5a998201c9934 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:59:36 +0100 Subject: [PATCH 1/5] feat: let plugins show a side panel next to Calendar, Contacts, Files and Settings --- .../__tests__/calendar-side-panel.test.tsx | 152 +++++++++ components/calendar/calendar-app.tsx | 10 + components/calendar/calendar-toolbar.tsx | 6 + .../__tests__/contacts-side-panel.test.tsx | 95 ++++++ components/contacts/contacts-app.tsx | 35 ++- components/email/email-viewer.tsx | 16 +- .../files/__tests__/files-side-panel.test.tsx | 158 ++++++++++ components/files/file-browser.tsx | 21 +- components/files/files-header.tsx | 9 +- components/layout/phone-header-actions.tsx | 3 + .../plugins/__tests__/app-side-panel.test.tsx | 201 ++++++++++++ components/plugins/app-side-panel.tsx | 297 ++++++++++++++++++ .../__tests__/settings-side-panel.test.tsx | 118 +++++++ components/settings/settings-app.tsx | 42 ++- lib/__tests__/plugin-app-side-panel.test.ts | 80 +++++ lib/plugin-sandbox/assistant/f.ts | 6 +- lib/plugin-sandbox/assistant/f.types.ts | 66 +++- lib/plugin-sandbox/panel-requests.ts | 11 +- lib/plugin-sandbox/permissions.ts | 21 +- lib/plugin-sandbox/slot-layout.ts | 2 +- lib/plugin-slot-catalog.ts | 7 + locales/ar/common.json | 4 + locales/ca/common.json | 4 + locales/cs/common.json | 4 + locales/da/common.json | 4 + locales/de/common.json | 4 + locales/en/common.json | 4 + locales/es/common.json | 4 + locales/et/common.json | 4 + locales/fa/common.json | 4 + locales/fr/common.json | 4 + locales/he/common.json | 4 + locales/hu/common.json | 4 + locales/it/common.json | 4 + locales/ja/common.json | 4 + locales/ko/common.json | 4 + locales/lv/common.json | 4 + locales/mn/common.json | 4 + locales/nb/common.json | 4 + locales/nl/common.json | 4 + locales/pl/common.json | 4 + locales/pt/common.json | 4 + locales/ro/common.json | 4 + locales/ru/common.json | 4 + locales/sk/common.json | 4 + locales/th/common.json | 4 + locales/tr/common.json | 4 + locales/uk/common.json | 4 + locales/vi/common.json | 4 + locales/zh-TW/common.json | 4 + locales/zh/common.json | 4 + 51 files changed, 1441 insertions(+), 35 deletions(-) create mode 100644 components/calendar/__tests__/calendar-side-panel.test.tsx create mode 100644 components/contacts/__tests__/contacts-side-panel.test.tsx create mode 100644 components/files/__tests__/files-side-panel.test.tsx create mode 100644 components/plugins/__tests__/app-side-panel.test.tsx create mode 100644 components/plugins/app-side-panel.tsx create mode 100644 components/settings/__tests__/settings-side-panel.test.tsx create mode 100644 lib/__tests__/plugin-app-side-panel.test.ts 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..cb6214ec4 --- /dev/null +++ b/components/calendar/__tests__/calendar-side-panel.test.tsx @@ -0,0 +1,152 @@ +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_hide'); + + // 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 2e237ad33..5802e2a06 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -113,6 +113,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"; @@ -1751,6 +1752,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; @@ -2098,6 +2104,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = /> ) : null} onCloseSearch={leaveSearch} + panelToggle={sidePanel.toggle} /> ); @@ -2277,6 +2284,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..4c8ca8286 --- /dev/null +++ b/components/contacts/__tests__/contacts-side-panel.test.tsx @@ -0,0 +1,95 @@ +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'; + +// 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')); +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: () =>