From 22903cdfc059a5eff464fc3f78860a277973a76e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 10:51:19 +0100 Subject: [PATCH] feat: search the screen you're on and import calendars and contacts from the desktop app's menus --- .../calendar-desktop-menu-ux-pim.test.tsx | 86 +++++++++++++++++ components/calendar/calendar-app.tsx | 2 + .../contacts-desktop-menu-ux-pim.test.tsx | 96 +++++++++++++++++++ components/contacts/contact-list.tsx | 3 + components/contacts/contacts-app.tsx | 2 +- .../desktop/__tests__/desktop-host.test.tsx | 30 ++++++ components/desktop/desktop-host.tsx | 16 +++- .../file-browser-desktop-menu-ux-pim.test.tsx | 90 +++++++++++++++++ components/files/file-browser.tsx | 4 + lib/app-commands.ts | 2 +- lib/desktop/languages.ts | 6 ++ locales/ar/common.json | 8 ++ locales/ca/common.json | 8 ++ locales/cs/common.json | 8 ++ locales/da/common.json | 8 ++ locales/de/common.json | 8 ++ locales/en/common.json | 8 ++ locales/es/common.json | 8 ++ locales/et/common.json | 8 ++ locales/fa/common.json | 8 ++ locales/fr/common.json | 8 ++ locales/he/common.json | 8 ++ locales/hu/common.json | 8 ++ locales/it/common.json | 8 ++ locales/ja/common.json | 8 ++ locales/ko/common.json | 8 ++ locales/lv/common.json | 8 ++ locales/mn/common.json | 8 ++ locales/nb/common.json | 8 ++ locales/nl/common.json | 8 ++ locales/pl/common.json | 8 ++ locales/pt/common.json | 8 ++ locales/ro/common.json | 8 ++ locales/ru/common.json | 8 ++ locales/sk/common.json | 8 ++ locales/th/common.json | 8 ++ locales/tr/common.json | 8 ++ locales/uk/common.json | 8 ++ locales/vi/common.json | 8 ++ locales/zh-TW/common.json | 8 ++ locales/zh/common.json | 8 ++ 41 files changed, 574 insertions(+), 3 deletions(-) create mode 100644 components/calendar/__tests__/calendar-desktop-menu-ux-pim.test.tsx create mode 100644 components/contacts/__tests__/contacts-desktop-menu-ux-pim.test.tsx create mode 100644 components/files/__tests__/file-browser-desktop-menu-ux-pim.test.tsx diff --git a/components/calendar/__tests__/calendar-desktop-menu-ux-pim.test.tsx b/components/calendar/__tests__/calendar-desktop-menu-ux-pim.test.tsx new file mode 100644 index 000000000..0fc99ee3e --- /dev/null +++ b/components/calendar/__tests__/calendar-desktop-menu-ux-pim.test.tsx @@ -0,0 +1,86 @@ +import { act, cleanup, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Calendar } from '@/lib/jmap/types'; +import { useCalendarStore } from '@/stores/calendar-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { useTaskStore } from '@/stores/task-store'; + +// pim-15: the desktop app's File > Import > Calendar…, on the whole calendar page +// with the shell around it stubbed out. + +vi.mock('@/hooks/use-media-query', () => ({ + useIsMobile: () => false, + useIsDesktop: () => true, + useIsFullDesktop: () => true, + useIsTablet: () => false, + 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 })); + +import { CalendarApp } from '../calendar-app'; +import { requestAppCommand } from '@/lib/app-commands'; + +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[]; + +const client = { + getAccountId: () => 'acc', + getMaxParticipantsPerEvent: () => null, + supportsCalendars: () => true, + queryCalendarEvents: vi.fn().mockResolvedValue([]), + getCalendarEvent: vi.fn().mockResolvedValue(null), + getCalendarTasks: vi.fn().mockResolvedValue([]), +}; + +beforeEach(() => { + config.demoMode = false; + useAuthStore.setState({ isAuthenticated: true, client: client as never, isLoading: false, activeAccountId: 'acc' }); + useCalendarStore.setState({ + calendars, events: [], supportsCalendar: true, selectedCalendarIds: ['cal'], viewMode: 'week', + selectedDate: new Date(2026, 9, 12), isLoading: false, isLoadingEvents: false, error: null, + fetchCalendars: vi.fn(), fetchEvents: vi.fn(), refreshAllSubscriptions: vi.fn(), + } as never); + useSettingsStore.setState({ enableCalendarTasks: true, proInterface: false } as never); + useTaskStore.setState({ tasks: [] } as never); +}); + +afterEach(() => cleanup()); + +describe('Calendar in the desktop menus', () => { + it('File > Import > Calendar… opens the calendar import', () => { + render(); + expect(screen.queryByRole('dialog', { name: 'title' })).toBeNull(); + let taken = false; + act(() => { taken = requestAppCommand('import-calendar'); }); + expect(taken).toBe(true); + expect(screen.getByRole('dialog', { name: 'title' })).toBeInTheDocument(); + }); + + it('opens nothing in demo mode, where the toolbar has no import either', () => { + config.demoMode = true; + render(); + act(() => { requestAppCommand('import-calendar'); }); + expect(screen.queryByRole('dialog', { name: 'title' })).toBeNull(); + }); +}); diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index fb964a7d7..34cd703a7 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -1677,6 +1677,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = useAppCommands({ "new-event": () => openCreateModal(), "new-task": () => { if (enableCalendarTasks) openCreateTaskModal(); }, + "import-calendar": () => { if (!demoMode) setShowImportModal(true); }, }, true); // Its View menu's Day, Week, Month and Agenda, and Edit > Search events. const viewFromMenu = (mode: CalendarViewMode) => () => { setMobileReturnToMonth(false); leaveSearch(); setViewMode(mode); }; @@ -1693,6 +1694,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = "view.calendar.agenda": { enabled: true, checked: normalizedViewMode === "agenda" }, "edit.search": normalizedViewMode !== "tasks", "file.newTask": enableCalendarTasks, + "file.importCalendar": !demoMode, }); useRegisterCommands(tSidebar("calendar"), [ diff --git a/components/contacts/__tests__/contacts-desktop-menu-ux-pim.test.tsx b/components/contacts/__tests__/contacts-desktop-menu-ux-pim.test.tsx new file mode 100644 index 000000000..31d674048 --- /dev/null +++ b/components/contacts/__tests__/contacts-desktop-menu-ux-pim.test.tsx @@ -0,0 +1,96 @@ +import { act, cleanup, render, screen, waitFor } 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 type { BulwarkDesktopBridge } from '@/lib/desktop/types'; + +// pim-15: the desktop app's Edit > Search contacts and File > Import > Contacts…, +// on the whole Contacts app with the shell around it stubbed out. + +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: () =>