From b8b7a4ed1569f5e17d0efbea798069607736c8ec Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 04:57:00 +0200 Subject: [PATCH] feat: let plugins add pages of their own with an entry in the navigation --- .../[locale]/apps/[[...segments]]/page.tsx | 26 +++ .../navigation-plugin-pages.test.tsx | 67 +++++++ components/layout/mv-top-bar.tsx | 26 +++ components/layout/navigation-rail.tsx | 70 +++++++- .../__tests__/plugin-page-app.test.tsx | 104 +++++++++++ components/plugins/plugin-page-app.tsx | 165 ++++++++++++++++++ hooks/use-plugin-pages.ts | 20 +++ locales/ar/common.json | 6 + locales/ca/common.json | 6 + locales/cs/common.json | 6 + locales/da/common.json | 6 + locales/de/common.json | 6 + locales/en/common.json | 6 + locales/es/common.json | 6 + locales/fa/common.json | 6 + locales/fr/common.json | 6 + locales/he/common.json | 6 + locales/hu/common.json | 6 + locales/it/common.json | 6 + locales/ja/common.json | 6 + locales/ko/common.json | 6 + locales/lv/common.json | 6 + locales/mn/common.json | 6 + locales/nb/common.json | 6 + locales/nl/common.json | 6 + locales/pl/common.json | 6 + locales/pt/common.json | 6 + locales/ro/common.json | 6 + locales/ru/common.json | 6 + locales/sk/common.json | 6 + locales/tr/common.json | 6 + locales/uk/common.json | 6 + locales/zh-TW/common.json | 6 + locales/zh/common.json | 6 + 34 files changed, 639 insertions(+), 1 deletion(-) create mode 100644 app/(main)/[locale]/apps/[[...segments]]/page.tsx create mode 100644 components/layout/__tests__/navigation-plugin-pages.test.tsx create mode 100644 components/plugins/__tests__/plugin-page-app.test.tsx create mode 100644 components/plugins/plugin-page-app.tsx create mode 100644 hooks/use-plugin-pages.ts diff --git a/app/(main)/[locale]/apps/[[...segments]]/page.tsx b/app/(main)/[locale]/apps/[[...segments]]/page.tsx new file mode 100644 index 000000000..bd91cb2f0 --- /dev/null +++ b/app/(main)/[locale]/apps/[[...segments]]/page.tsx @@ -0,0 +1,26 @@ +import { PluginPageApp } from "@/components/plugins/plugin-page-app"; +import { generateLiteSegmentParams } from "@/lib/lite-static-params"; + +// Pages plugins add with api.ui.setPages: /apps//[/]. +// Anything after the page id reaches the plugin's `app-page` slot as `path`. +// A catch-all rather than [pluginId]/[pageId] so the static Lite export can +// prerender the bare route; plugins never run in Lite, so it only ever shows +// the "not available" note there (lib/lite-static-params.ts). +export const generateStaticParams = generateLiteSegmentParams; + +function decodeOnce(segment: string): string { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } +} + +export default async function PluginPageRoute({ + params, +}: { + params: Promise<{ segments?: string[] }>; +}) { + const { segments } = await params; + return ; +} diff --git a/components/layout/__tests__/navigation-plugin-pages.test.tsx b/components/layout/__tests__/navigation-plugin-pages.test.tsx new file mode 100644 index 000000000..e0e84e679 --- /dev/null +++ b/components/layout/__tests__/navigation-plugin-pages.test.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Every plugin page gets an entry in the navigation, after the built-in apps: +// the rail (desktop), the bottom bar (phones) and Mountain View's app grid. + +vi.mock('../account-switcher', () => ({ AccountSwitcher: () => null })); +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/components/keyboard-shortcuts-modal', () => ({ KeyboardShortcutsModal: () => null })); +vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null })); +vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) })); +vi.mock('@/hooks/use-stalwart-admin', () => ({ useStalwartAdmin: () => false })); +vi.mock('@/i18n/navigation', () => ({ + useRouter: () => ({ push: vi.fn(), back: vi.fn(), replace: vi.fn() }), + usePathname: () => '/apps/kanban/board', + Link: ({ children, href, ...rest }: { children: React.ReactNode; href: string }) => {children}, +})); + +const { NavigationRail } = await import('../navigation-rail'); +const { MvTopBar } = await import('../mv-top-bar'); +const pages = await import('@/lib/plugin-sandbox/pages'); +const { useUpdateStore } = await import('@/stores/update-store'); + +beforeEach(() => { + useUpdateStore.setState({ startPolling: vi.fn() } as never); +}); +afterEach(() => pages.resetPages()); + +const register = () => act(() => pages.setPluginPages('kanban', 'Kanban', [ + { id: 'board', title: 'Board', icon: 'LayoutGrid' }, + { id: 'stats', title: 'Stats' }, +])); + +describe.each(['vertical', 'horizontal'] as const)('navigation rail (%s)', (orientation) => { + it('lists no plugin pages when none are registered', () => { + const { container } = render(); + expect(container.querySelector('[data-plugin-page-link]')).toBeNull(); + }); + + it('links each page after the built-in apps and marks the open one', () => { + register(); + const { container } = render(); + + const links = [...container.querySelectorAll('[data-plugin-page-link]')]; + expect(links.map((a) => a.getAttribute('href'))).toEqual(['/apps/kanban/board', '/apps/kanban/stats']); + const mail = container.querySelector('a[href="/"]')!; + expect(mail.compareDocumentPosition(links[0]) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(links[0]).toHaveAttribute('aria-current', 'page'); + expect(links[1]).not.toHaveAttribute('aria-current'); + // Title in the entry (as label on the collapsed rail). + expect(screen.getAllByText('Board').length + screen.queryAllByLabelText('Board').length).toBeGreaterThan(0); + }); +}); + +describe('Mountain View app grid', () => { + it('offers plugin pages next to the built-in apps', () => { + register(); + const { container } = render(); + fireEvent.click(screen.getByRole('button', { name: 'add_app' })); + + const links = [...container.querySelectorAll('[data-plugin-page-link]')]; + expect(links.map((a) => a.getAttribute('href'))).toEqual(['/apps/kanban/board', '/apps/kanban/stats']); + expect(links[0]).toHaveAttribute('role', 'menuitem'); + expect(screen.getByText('Stats')).toBeInTheDocument(); + }); +}); diff --git a/components/layout/mv-top-bar.tsx b/components/layout/mv-top-bar.tsx index f5d15678a..d97aefa67 100644 --- a/components/layout/mv-top-bar.tsx +++ b/components/layout/mv-top-bar.tsx @@ -9,6 +9,7 @@ import { HelpCircle, Mail, Menu, + Puzzle, Settings, Shield, SlidersHorizontal, @@ -32,6 +33,7 @@ import { cn } from "@/lib/utils"; import { AccountSwitcher } from "@/components/layout/account-switcher"; import { SearchBox, type ContactSearchField } from "@/components/search/search-box"; import { PluginActions } from "@/components/plugins/plugin-actions"; +import { usePluginPages, pluginPageHref, isPluginPageActive } from "@/hooks/use-plugin-pages"; import type { ContactSuggestion } from "@/lib/search-suggestions"; /** The top bar is not about any one item: plugin clicks carry no context. */ @@ -111,6 +113,7 @@ export function MvTopBar({ // The same gate the navigation rail puts on its "add app" button. const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled("sidebarAppsEnabled")); const sidebarApps = useResolvedSidebarApps(); + const pluginPages = usePluginPages(); const isStalwartAdmin = useStalwartAdmin(); const hasUpdate = useUpdateStore(selectHasUpdate); // The navigation rail starts the update check, and this bar replaces it on @@ -330,6 +333,29 @@ export function MvTopBar({ ); })} + {pluginPages.map((page) => { + const PageIcon = iconForName(page.icon) ?? Puzzle; + return ( + { + if (activeAppId) onCloseInlineApp?.(); + setAppsOpen(false); + }} + className={cn( + "flex flex-col items-center gap-1.5 rounded-lg py-3 px-1 text-center transition-colors hover:bg-muted", + !activeAppId && isPluginPageActive(pathname, page) && "bg-muted" + )} + > + + {page.title} + + ); + })} {sidebarApps.map((app) => { const AppIcon = iconForName(app.icon); return ( diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx index 2021e30ed..765f6f2a6 100644 --- a/components/layout/navigation-rail.tsx +++ b/components/layout/navigation-rail.tsx @@ -2,7 +2,7 @@ import { useState, useRef, useEffect, useLayoutEffect, useCallback } from "react"; import { createPortal } from "react-dom"; -import { Mail, Calendar, BookUser, HardDrive, Settings, Keyboard, Plus, Shield, LogOut, Check, Search } from "@/components/icons"; +import { Mail, Calendar, BookUser, HardDrive, Settings, Keyboard, Plus, Shield, LogOut, Check, Search, Puzzle } from "@/components/icons"; import { AccountSwitcher } from "./account-switcher"; import { iconForName } from "@/components/icons"; import { useConfig } from "@/hooks/use-config"; @@ -27,6 +27,7 @@ import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal"; import { getPathPrefix, withBasePath } from "@/lib/browser-navigation"; import { Avatar } from "@/components/ui/avatar"; import { toUnicodeEmail } from "@/lib/idn"; +import { usePluginPages, pluginPageHref, isPluginPageActive } from "@/hooks/use-plugin-pages"; interface NavItem { id: string; @@ -220,6 +221,8 @@ export function NavigationRail({ // Operator-provided apps (#931) plus the user's own; the gate above only // removes the user's, so a pinned set still shows when custom apps are off. const visibleSidebarApps = useResolvedSidebarApps(); + // Pages plugins added (api.ui.setPages); listed after the built-in apps. + const pluginPages = usePluginPages(); const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0; const isStalwartAdmin = useStalwartAdmin(); const hasUpdate = useUpdateStore(selectHasUpdate); @@ -382,6 +385,40 @@ export function NavigationRail({ ); })} + {/* Plugin pages, after the built-in apps */} + {pluginPages.map((page) => { + const isActive = !activeAppId && isPluginPageActive(pathname, page); + const PageIcon = iconForName(page.icon) ?? Puzzle; + return ( + { if (activeAppId) onCloseInlineApp?.(); }} + data-plugin-page-link={`${page.pluginId}/${page.id}`} + title={page.pluginName} + className={cn( + "flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]", + "transition-colors duration-150", + isActive + ? "text-primary" + : "text-muted-foreground hover:text-foreground" + )} + aria-current={isActive ? "page" : undefined} + > +
+ + {isActive && !mountainView && ( + + )} +
+ {page.title} + + ); + })} + {/* Custom sidebar apps (per-app mobile visibility) */} {visibleSidebarApps.filter((app) => app.showOnMobile).map((app) => { const AppIcon = iconForName(app.icon); @@ -554,6 +591,37 @@ export function NavigationRail({ ); })} + {/* Plugin pages, after the built-in apps */} + {pluginPages.map((page) => { + const isActive = !activeAppId && isPluginPageActive(pathname, page); + const PageIcon = iconForName(page.icon) ?? Puzzle; + return ( + { if (activeAppId) onCloseInlineApp?.(); }} + data-plugin-page-link={`${page.pluginId}/${page.id}`} + className={cn( + "relative flex items-center gap-2.5 rounded-md transition-colors duration-150", + collapsed + ? "justify-center w-10 h-10" + : "px-2.5 text-sm", + "max-lg:min-h-[44px]", + isActive + ? "bg-primary/10 text-primary font-medium" + : "text-muted-foreground hover:bg-muted hover:text-foreground" + )} + aria-current={isActive ? "page" : undefined} + aria-label={collapsed ? page.title : undefined} + title={collapsed ? `${page.title} (${page.pluginName})` : page.pluginName} + style={collapsed ? undefined : { paddingBlock: 'var(--density-sidebar-py)' }} + > + + {!collapsed && {page.title}} + + ); + })} + {/* Custom sidebar apps */} {visibleSidebarApps.length > 0 && (
/, inside the +// app's own frame. + +const createSlotInstance = vi.fn((..._args: unknown[]) => ({ destroy: vi.fn(), updateProps: vi.fn(), setTheme: vi.fn() })); +vi.mock('@/lib/plugin-sandbox/host-bridge', () => ({ + createSlotInstance: (...args: unknown[]) => createSlotInstance(...args), +})); +vi.mock('@/lib/plugin-sandbox/host-theme', () => ({ snapshotHostTheme: () => ({}) })); +vi.mock('@/components/layout/navigation-rail', () => ({ + NavigationRail: ({ orientation }: { orientation?: string }) =>