mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 08:15:41 +00:00
Merge branch 'feat/assistant-everywhere' into feat/assistant-b
This commit is contained in:
@@ -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<typeof import('@/lib/lite')>()),
|
||||
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> = {}): 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<CalendarEvent>) => ({ ...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(<CalendarApp />);
|
||||
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(<CalendarApp />);
|
||||
const toggle = document.querySelector<HTMLElement>('[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<HTMLElement>('[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(<CalendarApp />); });
|
||||
expect(document.querySelector('[data-plugin-panel-toggle]')).toBeNull();
|
||||
expect(frame()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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). */}
|
||||
<div
|
||||
ref={sidePanel.rowRef}
|
||||
className="flex flex-1 overflow-hidden relative isolate"
|
||||
data-tour="calendar-view"
|
||||
// Where the focus goes after an event is saved or deleted and
|
||||
@@ -2353,6 +2361,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
<Plus className="h-6 w-6" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{sidePanel.panel}
|
||||
</div>
|
||||
</AppMain>
|
||||
</div>
|
||||
|
||||
@@ -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({
|
||||
))}
|
||||
</ContextMenu>
|
||||
</div>
|
||||
|
||||
{/* The row is px-3; the margin puts the button where the reader has it (px-4, lg:px-6). */}
|
||||
{panelToggle && <div className="flex items-center ms-1 me-1 lg:me-3" data-panel-toggle-slot="">{panelToggle}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<typeof import('@/hooks/use-media-query')>()),
|
||||
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: () => <nav data-testid="rail" /> }));
|
||||
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-sidebar-apps', () => ({
|
||||
useSidebarApps: () => ({
|
||||
showAppsModal: false, inlineApp: null, loadedApps: [], handleManageApps: vi.fn(),
|
||||
handleInlineApp: vi.fn(), closeInlineApp: vi.fn(), closeAppsModal: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { ContactsApp } from '../contacts-app';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useContactStore } from '@/stores/contact-store';
|
||||
|
||||
const alice: ContactCard = {
|
||||
id: 'c-alice',
|
||||
addressBookIds: {},
|
||||
name: { components: [{ kind: 'given', value: 'Alice' }, { kind: 'surname', value: 'Smith' }], isOrdered: true },
|
||||
emails: { e0: { address: 'alice@example.com' } },
|
||||
};
|
||||
const bob: ContactCard = {
|
||||
id: 'c-bob',
|
||||
addressBookIds: {},
|
||||
name: { components: [{ kind: 'given', value: 'Bob' }, { kind: 'surname', value: 'Jones' }], isOrdered: true },
|
||||
};
|
||||
|
||||
function renderContacts() {
|
||||
act(() => {
|
||||
useAuthStore.setState({ isAuthenticated: true, client: { supportsPrincipals: () => false } as unknown as IJMAPClient, isLoading: false });
|
||||
useContactStore.setState({
|
||||
contacts: [alice, bob], addressBooks: [], selectedContactId: null, selectedContactIds: new Set(),
|
||||
searchQuery: '', supportsSync: false, contactsLoaded: true,
|
||||
});
|
||||
});
|
||||
return render(<ContactsApp />);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
media.mobile = false;
|
||||
setActionDispatcher(null);
|
||||
clearSlotOffers();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
const frame = () => screen.queryByTestId('plugin-frame-app-side-panel');
|
||||
|
||||
describe('Contacts side panel', () => {
|
||||
it('adds no row without a plugin', () => {
|
||||
renderContacts();
|
||||
expect(document.querySelector('[data-contacts-panel-row]')).toBeNull();
|
||||
expect(document.querySelector('[data-plugin-panel-toggle]')).toBeNull();
|
||||
});
|
||||
|
||||
it("puts the open contact's name and the button in an h-14 row over the contact and the panel", () => {
|
||||
offerSlots('p1', ['app-side-panel']);
|
||||
renderContacts();
|
||||
const row = document.querySelector<HTMLElement>('[data-contacts-panel-row]')!;
|
||||
expect(row.className).toContain('h-14');
|
||||
const toggle = row.querySelector<HTMLElement>('[data-plugin-panel-toggle]')!;
|
||||
expect(row.lastElementChild).toBe(toggle);
|
||||
expect(toggle).toHaveAccessibleName('plugin_panel');
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(framePropsOf(frame()!)).toEqual({ view: 'contacts', context: { selectedCount: 0 }, focusRequest: 0 });
|
||||
// The panel is the last thing in the row under it.
|
||||
const panel = document.querySelector<HTMLElement>('[data-app-side-panel]')!;
|
||||
expect(panel.parentElement!.lastElementChild).toBe(panel);
|
||||
expect(row.nextElementSibling).toBe(panel.parentElement);
|
||||
|
||||
fireEvent.click(screen.getByText('Alice Smith'));
|
||||
expect(row.firstElementChild).toHaveTextContent('Alice Smith');
|
||||
expect(framePropsOf(frame()!).context).toMatchObject({ contact: { id: 'c-alice', fullName: 'Alice Smith', emails: ['alice@example.com'] }, selectedCount: 0 });
|
||||
|
||||
fireEvent.click(toggle);
|
||||
expect(frame()).toBeNull();
|
||||
// The row and its button stay, so the panel can come back.
|
||||
expect(row.querySelector('[data-plugin-panel-toggle]')).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
it('keeps what was typed in a contact form when the panel starts docking', () => {
|
||||
renderContacts();
|
||||
fireEvent.click(screen.getByText('Alice Smith'));
|
||||
fireEvent.click(screen.getByRole('button', { name: /detail.edit|edit/ }));
|
||||
fireEvent.change(screen.getByPlaceholderText('middle_name'), { target: { value: 'Marie' } });
|
||||
const field = screen.getByPlaceholderText('middle_name');
|
||||
act(() => offerSlots('p1', ['app-side-panel']));
|
||||
expect(document.querySelector('[data-contacts-panel-row]')).not.toBeNull();
|
||||
// The same field, with what the user typed.
|
||||
expect(field.isConnected).toBe(true);
|
||||
expect(screen.getByPlaceholderText('middle_name')).toHaveValue('Marie');
|
||||
});
|
||||
|
||||
it('puts the phone button over an open contact, where the list and its header are gone', () => {
|
||||
media.mobile = true;
|
||||
const dispatch = vi.fn().mockResolvedValue(undefined);
|
||||
setActionDispatcher(dispatch);
|
||||
offerSlots('p1', ['app-side-panel']);
|
||||
renderContacts();
|
||||
fireEvent.click(screen.getByText('Alice Smith'));
|
||||
const buttons = document.querySelectorAll<HTMLElement>('[data-app-side-panel-phone]');
|
||||
expect(buttons).toHaveLength(1);
|
||||
fireEvent.click(buttons[0]);
|
||||
const [, surface, action, ctx] = dispatch.mock.calls[0];
|
||||
expect([surface, action]).toEqual(['app-side-panel', 'open']);
|
||||
expect((ctx as { context: { contact?: { fullName: string } } }).context.contact?.fullName).toBe('Alice Smith');
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,8 @@ import { DirectoryEntryDetail } from "@/components/contacts/directory-entry-deta
|
||||
import { PromptDialog } from "@/components/ui/prompt-dialog";
|
||||
import { exportContacts } from "@/components/contacts/contact-export";
|
||||
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
||||
import { AppSidePanelPhoneButton, useAppSidePanel } from "@/components/plugins/app-side-panel";
|
||||
import { contactToView } from "@/lib/plugin-projection";
|
||||
import { useStrictestDevices } from "@/hooks/use-device-policy";
|
||||
import { useContactStore, getContactDisplayName, getContactPrimaryEmail, type DirectoryEntry } from "@/stores/contact-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
@@ -389,6 +391,20 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
const selectedGroup = selectedGroupId ? contacts.find(c => c.id === selectedGroupId) || null : null;
|
||||
const selectedGroupMembers = useMemo(() => selectedGroupId ? getGroupMembers(selectedGroupId) : [], [selectedGroupId, getGroupMembers]);
|
||||
|
||||
// The plugin side panel (app-side-panel), with the contact the user opened
|
||||
// and how many are ticked. On desktop it sits under a row of its own with
|
||||
// the open contact's name and the panel's button.
|
||||
const openContact = (view === "detail" || view === "edit") && activeCategory !== "directory" && selectedContact?.kind !== "group" ? selectedContact : null;
|
||||
const selectedCount = selectedContactIds.size;
|
||||
const sidePanelContext = useMemo(() => ({
|
||||
...(openContact ? { contact: contactToView(openContact) } : {}),
|
||||
selectedCount,
|
||||
}), [openContact, selectedCount]);
|
||||
const sidePanel = useAppSidePanel({ view: "contacts", context: sidePanelContext });
|
||||
const sidePanelTitle = openContact
|
||||
? getContactDisplayName(openContact)
|
||||
: view === "group-detail" && selectedGroup ? getContactDisplayName(selectedGroup) : "";
|
||||
|
||||
// Collect all unique keywords across contacts
|
||||
const allKeywords = useMemo(() => {
|
||||
const kws = new Set<string>();
|
||||
@@ -1429,11 +1445,20 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Panel 3: Detail / Form */}
|
||||
{/* Panel 3: Detail / Form. With a plugin side panel, a row with the
|
||||
contact's name and the panel's button sits over it, like the
|
||||
reader's toolbar. The same element either way, so a form keeps
|
||||
what was typed when the panel starts or stops docking. */}
|
||||
{showRightPanel && (
|
||||
<div className="flex-1 min-w-0 flex flex-col">
|
||||
<div className="flex-1 min-w-0 flex flex-col" data-contacts-side-panel-column="">
|
||||
{sidePanel.docks && (
|
||||
<div className="flex items-center gap-2 h-14 box-content border-b border-border px-2 sm:px-4 lg:px-6 shrink-0" data-contacts-panel-row="">
|
||||
<div className="flex-1 min-w-0 truncate text-sm font-semibold" title={sidePanelTitle || undefined}>{sidePanelTitle}</div>
|
||||
{sidePanel.toggle}
|
||||
</div>
|
||||
)}
|
||||
{isMobile && (
|
||||
<div className="px-3 py-2 border-b border-border">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -1443,10 +1468,15 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
<ArrowLeft className="w-4 h-4 me-2" />
|
||||
{returnToEmail ? t("back_to_email") : t("back_to_contacts")}
|
||||
</Button>
|
||||
{/* The list's header with its button is hidden while a contact is open. */}
|
||||
<AppSidePanelPhoneButton className="ms-auto" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-h-0">
|
||||
{renderRightPanel()}
|
||||
<div ref={sidePanel.rowRef} className="relative flex flex-1 min-h-0">
|
||||
<div className="flex-1 min-w-0 min-h-0">
|
||||
{renderRightPanel()}
|
||||
</div>
|
||||
{sidePanel.panel}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -113,7 +113,7 @@ describe('email-detail-sidebar', () => {
|
||||
rerender(<EmailViewer email={textMail('Hi')} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
|
||||
|
||||
expect(screen.queryByTestId('slot-email-detail-sidebar')).toBeNull();
|
||||
expect(screen.getByRole('button', { name: 'plugin_panel_show' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'plugin_panel', pressed: false })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lays the panel over the message when opened anyway, instead of squeezing it', () => {
|
||||
@@ -121,7 +121,7 @@ describe('email-detail-sidebar', () => {
|
||||
offerSlots('p1', ['email-detail-sidebar']);
|
||||
viewer(textMail('Hi'));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin_panel_show' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin_panel', pressed: false }));
|
||||
const panel = screen.getByTestId('slot-email-detail-sidebar').closest('[data-plugin-detail-panel]')!;
|
||||
expect(panel.getAttribute('data-plugin-detail-panel')).toBe('overlay');
|
||||
expect(panel.className).toContain('absolute');
|
||||
@@ -168,7 +168,7 @@ describe('email-detail-sidebar', () => {
|
||||
offerSlots('p1', ['email-detail-sidebar'], { startFolded: ['email-detail-sidebar'] });
|
||||
const { unmount } = viewer(textMail('Hi'));
|
||||
expect(screen.queryByTestId('slot-email-detail-sidebar')).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin_panel_show' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin_panel', pressed: false }));
|
||||
expect(screen.getByTestId('slot-email-detail-sidebar').closest('[data-plugin-detail-panel]')!.getAttribute('data-plugin-detail-panel')).toBe('docked');
|
||||
unmount();
|
||||
viewer(textMail('Hi'));
|
||||
|
||||
@@ -57,14 +57,14 @@ describe('plugin detail panel and narrow messages', () => {
|
||||
renderViewer();
|
||||
|
||||
expect(screen.queryByTestId('slot-email-detail-sidebar')).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText('plugin_panel_show')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'plugin_panel', pressed: false })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens anyway when the user asks', () => {
|
||||
reportWidth = 640;
|
||||
renderViewer();
|
||||
|
||||
act(() => { fireEvent.click(screen.getByLabelText('plugin_panel_show')); });
|
||||
act(() => { fireEvent.click(screen.getByRole('button', { name: 'plugin_panel', pressed: false })); });
|
||||
|
||||
expect(screen.getByTestId('slot-email-detail-sidebar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -61,7 +61,7 @@ const show = () => render(
|
||||
/** The plugin panel, open or folded to its toggle. */
|
||||
const sidePanel = () =>
|
||||
(document.querySelector('[data-plugin-detail-panel]') as HTMLElement | null)
|
||||
?? (screen.getByLabelText('plugin_panel_show').closest('div') as HTMLElement);
|
||||
?? (screen.getByLabelText('plugin_panel').closest('div') as HTMLElement);
|
||||
|
||||
/** An item of the top toolbar. */
|
||||
const toolbarItem = () => document.querySelector('[data-overflow-item]') as HTMLElement;
|
||||
@@ -139,12 +139,15 @@ describe('the reader toolbar and the plugin side panel', () => {
|
||||
const toggle = document.querySelector('[data-plugin-panel-toggle]') as HTMLElement;
|
||||
// At the end of the toolbar, not in the panel.
|
||||
expect(toggle.closest('[data-plugin-detail-panel]')).toBeNull();
|
||||
expect(toggle.getAttribute('aria-label')).toBe('plugin_panel_hide');
|
||||
expect(toggle.getAttribute('aria-label')).toBe('plugin_panel');
|
||||
expect(toggle.getAttribute('aria-pressed')).toBe('true');
|
||||
// A landmark for screen readers, named after its plugin.
|
||||
expect(screen.getByRole('complementary')).toBe(document.querySelector('[data-plugin-detail-panel]'));
|
||||
act(() => { fireEvent.click(toggle); });
|
||||
expect(document.querySelector('[data-plugin-detail-panel]')).toBeNull();
|
||||
// Folded, the panel leaves no strip of its own: the toolbar button brings it back.
|
||||
expect(screen.getAllByLabelText('plugin_panel_show')).toHaveLength(1);
|
||||
act(() => { fireEvent.click(screen.getByLabelText('plugin_panel_show')); });
|
||||
expect(screen.getAllByLabelText('plugin_panel')).toHaveLength(1);
|
||||
act(() => { fireEvent.click(screen.getByLabelText('plugin_panel')); });
|
||||
expect(document.querySelector('[data-plugin-detail-panel]')).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -154,12 +157,13 @@ describe('the reader toolbar and the plugin side panel', () => {
|
||||
expect(row().hasAttribute('data-toolbar-toggle-only')).toBe(true);
|
||||
// As high as the full toolbar.
|
||||
expect(row().firstElementChild!.className).toContain('h-14');
|
||||
expect(theToggle().getAttribute('title')).toBe('plugin_panel_hide');
|
||||
expect(theToggle().getAttribute('title')).toBe('plugin_panel');
|
||||
expect(theToggle().getAttribute('aria-pressed')).toBe('true');
|
||||
act(() => { fireEvent.click(theToggle()); });
|
||||
// Folded: no strip and no row of the panel's own, the same button in the same row.
|
||||
expect(document.querySelector('[data-plugin-detail-panel]')).toBeNull();
|
||||
expect(document.querySelector('[data-reader-side-panel]')!.children).toHaveLength(0);
|
||||
expect(theToggle().getAttribute('aria-label')).toBe('plugin_panel_show');
|
||||
expect(theToggle().getAttribute('aria-label')).toBe('plugin_panel');
|
||||
expect(theToggle().getAttribute('aria-pressed')).toBe('false');
|
||||
act(() => { fireEvent.click(theToggle()); });
|
||||
// Open: the panel holds no button row above the plugin's own header.
|
||||
|
||||
@@ -40,6 +40,7 @@ import { useBottomControls } from "@/hooks/use-bottom-controls";
|
||||
import { useComposingSurface } from "@/hooks/use-composing-surface";
|
||||
import { ReaderSelectionMenu, useReaderSelectionMenu, watchBodySelection, watchElementSelection } from "./reader-selection-menu";
|
||||
import { onPanelRequest } from "@/lib/plugin-sandbox/panel-requests";
|
||||
import { PluginPanelToggle, panelLabelOf, panelShortcutOf } from "@/components/plugins/app-side-panel";
|
||||
import type { ComposerMetadata } from "@/lib/plugin-sandbox/composer-registry";
|
||||
import { QuickReplyPluginActions } from "./quick-reply-plugin-actions";
|
||||
import { quickReplyEnvelope } from "@/lib/outgoing/quick-reply-envelope";
|
||||
@@ -98,7 +99,6 @@ import {
|
||||
MapPin,
|
||||
StickyNote,
|
||||
PanelRightClose,
|
||||
PanelRightOpen,
|
||||
Send,
|
||||
FolderInput,
|
||||
Sun,
|
||||
@@ -1888,19 +1888,9 @@ export function EmailViewer({
|
||||
};
|
||||
// The one button that shows and hides the plugin side panel, at the end of
|
||||
// the toolbar row in every state of the reader (message, loading, none, scheduled).
|
||||
// The same button as in the other views' side panels (app-side-panel.tsx).
|
||||
const panelToggle = hasDetailSidebar && !isMobile ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleDetailSidebar}
|
||||
className={cn("h-8 w-8 shrink-0", mvViewer && "order-10")}
|
||||
title={t(sidebarCollapsed ? 'plugin_panel_show' : 'plugin_panel_hide')}
|
||||
aria-label={t(sidebarCollapsed ? 'plugin_panel_show' : 'plugin_panel_hide')}
|
||||
aria-pressed={!sidebarCollapsed}
|
||||
data-plugin-panel-toggle=""
|
||||
>
|
||||
{sidebarCollapsed ? <PanelRightOpen className="w-4 h-4 text-muted-foreground" /> : <PanelRightClose className="w-4 h-4 text-muted-foreground" />}
|
||||
</Button>
|
||||
<PluginPanelToggle open={!sidebarCollapsed} onClick={toggleDetailSidebar} className={cn(mvViewer && "order-10")} shortcut={panelShortcutOf(detailSlots.map((o) => o.pluginId))} />
|
||||
) : null;
|
||||
useEffect(() => onPanelRequest((request) => {
|
||||
if (request.slot !== 'email-detail-sidebar') return null;
|
||||
@@ -3911,6 +3901,8 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
role="complementary"
|
||||
aria-label={panelLabelOf(detailSlots.map((o) => o.pluginId), t('plugin_panel'))}
|
||||
data-plugin-detail-panel={sidebarOverlay ? 'overlay' : 'docked'}
|
||||
className={cn(
|
||||
"flex flex-col h-full border-s border-border bg-background overflow-hidden",
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { FileBrowser } from '../file-browser';
|
||||
import { useFileStore, type FileResource } from '@/stores/file-store';
|
||||
import { clearSlotOffers, framePropsOf, offerSlots } from '@/components/plugins/__tests__/fake-slot-offer';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
|
||||
// The plugin side panel (app-side-panel) in Files: its button right after
|
||||
// the details button, at the end of the header in both of its shapes, the
|
||||
// panel last in the row under it with the selected files and the folder,
|
||||
// and the details panel and it taking turns.
|
||||
|
||||
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
|
||||
vi.mock('@/components/settings/share-collection-dialog', () => ({ ShareCollectionDialog: () => null }));
|
||||
vi.mock('@/components/plugins/plugin-iframe-slot', () => import('@/components/plugins/__tests__/fake-slot-offer'));
|
||||
const device = vi.hoisted(() => ({ isMobile: false }));
|
||||
vi.mock('@/hooks/use-media-query', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/hooks/use-media-query')>()),
|
||||
useIsMobile: () => device.isMobile,
|
||||
}));
|
||||
|
||||
const folder = {
|
||||
id: 'f-docs', name: 'Documents', serverName: 'Documents', isDirectory: true, contentType: '', contentLength: 0,
|
||||
lastModified: '2026-10-01T00:00:00Z', blobId: null, parentId: null,
|
||||
} as FileResource;
|
||||
const notes = {
|
||||
id: 'f-notes', name: 'notes.txt', serverName: 'notes.txt', isDirectory: false, contentType: 'text/plain', contentLength: 10,
|
||||
lastModified: '2026-10-01T00:00:00Z', blobId: 'b-notes', parentId: null,
|
||||
} as FileResource;
|
||||
const photo = {
|
||||
id: 'f-photo', name: 'photo.png', serverName: 'photo.png', isDirectory: false, contentType: 'image/png', contentLength: 99,
|
||||
lastModified: '2026-10-02T00:00:00Z', blobId: 'b-photo', parentId: null,
|
||||
} as FileResource;
|
||||
|
||||
function renderBrowser(selected: string[] = []) {
|
||||
const client = { listAllFileNodesAcrossAccounts: vi.fn(async () => []) } as unknown as IJMAPClient;
|
||||
act(() => {
|
||||
useFileStore.setState({
|
||||
client, sharedRoots: [], allFolders: [folder], currentParentId: null, pathStack: [{ id: null, name: '' }], hasListed: true,
|
||||
});
|
||||
});
|
||||
const noop = vi.fn(async () => {});
|
||||
render(
|
||||
<FileBrowser
|
||||
currentPath="/"
|
||||
resources={[folder, notes, photo]}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
selectedResources={new Set(selected)}
|
||||
uploadProgress={null}
|
||||
clipboard={null}
|
||||
onNavigate={vi.fn()}
|
||||
onSetSelection={vi.fn()}
|
||||
onToggleSelect={vi.fn()}
|
||||
onPreviewFile={vi.fn()}
|
||||
onDownload={noop}
|
||||
onCreateFolder={noop}
|
||||
onUploadFiles={noop}
|
||||
onUploadFolder={noop}
|
||||
onCancelUpload={vi.fn()}
|
||||
onDelete={noop}
|
||||
onBatchDelete={noop}
|
||||
onRename={noop}
|
||||
onBatchDownload={noop}
|
||||
onRefresh={noop}
|
||||
onClearSelection={vi.fn()}
|
||||
onCut={vi.fn()}
|
||||
onCopy={vi.fn()}
|
||||
onPaste={noop}
|
||||
onMoveTo={noop}
|
||||
onPreviewImage={vi.fn()}
|
||||
onCreateFile={noop}
|
||||
onDuplicate={noop}
|
||||
getImageUrl={async () => ''}
|
||||
favorites={[]}
|
||||
recentFiles={[]}
|
||||
onToggleFavorite={vi.fn()}
|
||||
onOpenRecent={vi.fn()}
|
||||
onRenameNode={noop}
|
||||
onDeleteNode={noop}
|
||||
onDownloadNode={noop}
|
||||
onRevealNode={noop}
|
||||
onRemoveRecent={vi.fn()}
|
||||
client={client}
|
||||
ownAccountId="a"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
device.isMobile = false;
|
||||
localStorage.clear();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
clearSlotOffers();
|
||||
});
|
||||
|
||||
const frame = () => screen.queryByTestId('plugin-frame-app-side-panel');
|
||||
const toggle = () => document.querySelector<HTMLElement>('[data-plugin-panel-toggle]')!;
|
||||
const details = () => screen.getByRole('button', { name: 'details' });
|
||||
|
||||
describe('Files side panel', () => {
|
||||
it('has no button without a plugin', () => {
|
||||
renderBrowser();
|
||||
expect(document.querySelector('[data-plugin-panel-toggle]')).toBeNull();
|
||||
});
|
||||
|
||||
it('puts the button right after the details button, last in the header, and the folder in the context', () => {
|
||||
offerSlots('p1', ['app-side-panel']);
|
||||
renderBrowser();
|
||||
const header = details().closest('.h-14')!;
|
||||
const buttons: HTMLElement[] = Array.from(header.querySelectorAll('button'));
|
||||
expect(buttons.indexOf(toggle())).toBe(buttons.indexOf(details()) + 1);
|
||||
expect(buttons[buttons.length - 1]).toBe(toggle());
|
||||
const panel = document.querySelector<HTMLElement>('[data-app-side-panel]')!;
|
||||
expect(panel.parentElement!.lastElementChild).toBe(panel);
|
||||
expect(framePropsOf(frame()!)).toEqual({ view: 'files', context: { folder: { id: null, path: '/' } }, focusRequest: 0 });
|
||||
});
|
||||
|
||||
it('names the selected files, and keeps the button last in the selection bar', () => {
|
||||
offerSlots('p1', ['app-side-panel']);
|
||||
renderBrowser(['notes.txt', 'photo.png']);
|
||||
const bar = screen.getByRole('toolbar', { name: 'selection_actions' });
|
||||
const buttons = Array.from(bar.querySelectorAll('button'));
|
||||
expect(buttons[buttons.length - 1]).toBe(toggle());
|
||||
expect(buttons[buttons.length - 2]).toBe(details());
|
||||
const context = framePropsOf(frame()!).context as { files: { name: string }[]; fileCount: number };
|
||||
expect(context.files.map((f) => f.name)).toEqual(['notes.txt', 'photo.png']);
|
||||
expect(context.fileCount).toBe(2);
|
||||
});
|
||||
|
||||
it('folds the details when the panel opens, and the panel when the details open', () => {
|
||||
offerSlots('p1', ['app-side-panel']);
|
||||
localStorage.setItem('appSidePanelCollapsed', '1');
|
||||
renderBrowser();
|
||||
expect(frame()).toBeNull();
|
||||
fireEvent.click(details());
|
||||
expect(details()).toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
fireEvent.click(toggle());
|
||||
expect(frame()).not.toBeNull();
|
||||
expect(details()).toHaveAttribute('aria-pressed', 'false');
|
||||
|
||||
fireEvent.click(details());
|
||||
expect(details()).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(frame()).toBeNull();
|
||||
expect(toggle()).toHaveAttribute('aria-pressed', 'false');
|
||||
// Only here: the other views keep the panel open as the user left it.
|
||||
expect(localStorage.getItem('appSidePanelCollapsed')).toBe('0');
|
||||
// The details go, the panel is back.
|
||||
fireEvent.click(details());
|
||||
expect(frame()).not.toBeNull();
|
||||
});
|
||||
|
||||
it('never shows both: with both stored open the details fold when the page loads', () => {
|
||||
offerSlots('p1', ['app-side-panel']);
|
||||
localStorage.setItem('appSidePanelCollapsed', '0');
|
||||
localStorage.setItem('files-show-details', '1');
|
||||
renderBrowser();
|
||||
expect(frame()).not.toBeNull();
|
||||
expect(details()).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(document.querySelector('[data-app-side-panel]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('has no button in the header on a phone', () => {
|
||||
device.isMobile = true;
|
||||
offerSlots('p1', ['app-side-panel']);
|
||||
act(() => renderBrowser());
|
||||
expect(document.querySelector('[data-plugin-panel-toggle]')).toBeNull();
|
||||
expect(frame()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,8 @@ import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { AppMain } from "@/components/layout/app-landmarks";
|
||||
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||
import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils";
|
||||
import { useAppSidePanel } from "@/components/plugins/app-side-panel";
|
||||
import { MAX_SIDE_PANEL_FILES } from "@/lib/plugin-sandbox/assistant/f.types";
|
||||
import { resourceToView, useFileStore, isSharedFilesPage, sharedFilePages, type FileResource } from "@/stores/file-store";
|
||||
import { TRASH_LOCATION, TRASH_PATH, isTrashLocation } from "@/lib/files-trash";
|
||||
import { PluginActions, usePluginActions } from "@/components/plugins/plugin-actions";
|
||||
@@ -438,6 +440,26 @@ export function FileBrowser({
|
||||
// What plugins see of the selection (file-actions, file-preview-section).
|
||||
const selectedViews = useMemo(() => selectedList.map((r) => resourceToView(r, currentPath)), [selectedList, currentPath]);
|
||||
|
||||
// The plugin side panel (app-side-panel), with the selected files and the
|
||||
// open folder. It shares its place with the details panel: opening it
|
||||
// folds the details, and while the details are open it steps aside here
|
||||
// only (the other views keep it as the user left it).
|
||||
const sidePanelContext = useMemo(() => ({
|
||||
...(selectedViews.length > 0 ? { files: selectedViews.slice(0, MAX_SIDE_PANEL_FILES), fileCount: selectedViews.length } : {}),
|
||||
folder: { id: currentParentId ?? null, path: currentPath },
|
||||
}), [selectedViews, currentParentId, currentPath]);
|
||||
const sidePanel = useAppSidePanel({ view: "files", context: sidePanelContext, hidden: showDetails, onOpen: () => toggleDetails(false) });
|
||||
// Both stored open (the details here, the panel in any view): once the
|
||||
// panel shows here the details fold, so the two never sit side by side.
|
||||
const sidePanelDocks = sidePanel.docks;
|
||||
const sidePanelWanted = sidePanel.wantsOpen;
|
||||
const sidePanelSettled = useRef(false);
|
||||
useEffect(() => {
|
||||
if (sidePanelSettled.current || !sidePanelDocks) return;
|
||||
sidePanelSettled.current = true;
|
||||
if (showDetails && sidePanelWanted) toggleDetails(false);
|
||||
}, [sidePanelDocks, sidePanelWanted, showDetails, toggleDetails]);
|
||||
|
||||
// ---- Breadcrumbs -----------------------------------------------------------
|
||||
const crumbs: Crumb[] = useMemo(() => {
|
||||
const segments = currentPath.split("/").filter(Boolean);
|
||||
@@ -1282,6 +1304,7 @@ export function FileBrowser({
|
||||
toolsHidden={accountPickerMode}
|
||||
selection={selectionActions}
|
||||
emptyTrash={inTrash && onEmptyTrash ? { onEmpty: onEmptyTrash, disabled: trashCount === 0 } : undefined}
|
||||
panelToggle={sidePanel.toggle}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
@@ -1295,7 +1318,7 @@ export function FileBrowser({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative flex flex-1 min-h-0">
|
||||
<div ref={sidePanel.rowRef} className="relative flex flex-1 min-h-0">
|
||||
<div
|
||||
ref={scrollAreaRef}
|
||||
className="relative flex-1 min-w-0 overflow-y-auto"
|
||||
@@ -1356,6 +1379,7 @@ export function FileBrowser({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{sidePanel.panel}
|
||||
</div>
|
||||
</AppMain>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
Menu, ChevronRight, Search, X, ArrowUpDown, Check, LayoutGrid, LayoutList,
|
||||
@@ -84,6 +84,8 @@ interface FilesHeaderProps {
|
||||
selection: SelectionActions | null;
|
||||
/** In the trash: the Empty trash button. */
|
||||
emptyTrash?: { onEmpty: () => void; disabled: boolean };
|
||||
/** The plugin side panel's button, after the details button (desktop and tablet). */
|
||||
panelToggle?: ReactNode;
|
||||
}
|
||||
|
||||
const MAX_VISIBLE_CRUMBS = 3;
|
||||
@@ -114,6 +116,7 @@ export function FilesHeader({
|
||||
toolsHidden,
|
||||
selection,
|
||||
emptyTrash,
|
||||
panelToggle,
|
||||
}: FilesHeaderProps) {
|
||||
const t = useTranslations("files");
|
||||
const sortMenu = useContextMenu<null>();
|
||||
@@ -122,6 +125,8 @@ export function FilesHeader({
|
||||
const [dropCrumb, setDropCrumb] = useState<string | null>(null);
|
||||
|
||||
const headerClass = "flex items-center gap-1 px-3 h-14 box-content border-b border-border min-w-0 flex-shrink-0";
|
||||
// The row is px-3; the margin puts the button where the reader has it (px-4, lg:px-6).
|
||||
const toggleSlot = panelToggle ? <div className="flex items-center shrink-0 me-1 lg:me-3" data-panel-toggle-slot="">{panelToggle}</div> : null;
|
||||
|
||||
if (selection) {
|
||||
return (
|
||||
@@ -169,6 +174,7 @@ export function FilesHeader({
|
||||
{compact && !selection.trash && <SelectionPluginActions files={selection.pluginFiles} mimeType={selection.pluginMimeType} overflow />}
|
||||
<div className="flex-1" />
|
||||
<DetailsToggle showDetails={showDetails} onToggle={onToggleDetails} label={t("details")} />
|
||||
{toggleSlot}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -360,6 +366,7 @@ export function FilesHeader({
|
||||
</ToolbarButton>
|
||||
)}
|
||||
<DetailsToggle showDetails={showDetails} onToggle={onToggleDetails} label={t("details")} />
|
||||
{toggleSlot}
|
||||
<PhoneHeaderActions
|
||||
below="md"
|
||||
menuItems={(close) => (
|
||||
|
||||
@@ -7,6 +7,7 @@ import { IconButton } from "@/components/ui/icon-button";
|
||||
import { ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
|
||||
import { AccountSwitcher } from "@/components/layout/account-switcher";
|
||||
import { PluginActions } from "@/components/plugins/plugin-actions";
|
||||
import { AppSidePanelPhoneButton } from "@/components/plugins/app-side-panel";
|
||||
import { useMenuNavigation } from "@/hooks/use-menu-navigation";
|
||||
import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query";
|
||||
import { openCommandPalette } from "@/lib/app-commands";
|
||||
@@ -140,6 +141,8 @@ export function PhoneHeaderActions({
|
||||
|
||||
return (
|
||||
<div data-phone-header-actions="" className={cn("flex items-center gap-1 flex-shrink-0 ms-auto", className)}>
|
||||
{/* A plugin's side panel of this view, which opens as a dialog on a phone. */}
|
||||
<AppSidePanelPhoneButton />
|
||||
<PhoneHeaderMenu menuItems={menuItems} />
|
||||
{/* Which account you are in is otherwise only visible in Settings.
|
||||
Last, so it sits on the far edge, mirrored under RTL by the flex row. */}
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import React from 'react';
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { clearOffers, FakeIframeSlot, offerSlots, slotProps } from './fake-plugin-offer';
|
||||
import { requestPanel, resetPanelRequests } from '@/lib/plugin-sandbox/panel-requests';
|
||||
import { setActionDispatcher } from '@/lib/plugin-sandbox/actions';
|
||||
import { deregister, register, type ActivePlugin } from '@/lib/plugin-sandbox/registry';
|
||||
import { registerShortcuts } from '@/lib/plugin-sandbox/shortcuts';
|
||||
|
||||
// The plugin side panel of Calendar, Contacts, Files and Settings
|
||||
// (app-side-panel): its button, folding, width, the overlay on a narrow
|
||||
// view, focus requests, the plugin's own requests, and the dialog where it
|
||||
// can't dock.
|
||||
|
||||
vi.mock('@/components/plugins/plugin-iframe-slot', () => ({ PluginIframeSlot: FakeIframeSlot }));
|
||||
const device = vi.hoisted(() => ({ isMobile: false }));
|
||||
vi.mock('@/hooks/use-media-query', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/hooks/use-media-query')>()),
|
||||
useIsMobile: () => device.isMobile,
|
||||
}));
|
||||
|
||||
const { useAppSidePanel, AppSidePanelPhoneButton, APP_SIDE_PANEL_COLLAPSED_KEY, APP_SIDE_PANEL_WIDTH_KEY } = await import('../app-side-panel');
|
||||
const { EmbeddedContext } = await import('@/hooks/use-is-embedded');
|
||||
import type { AppSidePanelContext, AppSidePanelViewName } from '@/lib/plugin-sandbox/assistant/f.types';
|
||||
|
||||
// One ResizeObserver per row; tests set the row's width with resizeRow().
|
||||
let observed: ((width: number) => void) | null = null;
|
||||
class FakeResizeObserver {
|
||||
constructor(private cb: ResizeObserverCallback) {}
|
||||
observe() { observed = (width) => this.cb([{ contentRect: { width } } as ResizeObserverEntry], this as unknown as ResizeObserver); }
|
||||
disconnect() { observed = null; }
|
||||
unobserve() {}
|
||||
}
|
||||
const resizeRow = (width: number) => act(() => observed?.(width));
|
||||
|
||||
const EVENT_CONTEXT: AppSidePanelContext = { page: { id: 'reading', label: 'Reading' } };
|
||||
|
||||
function Harness({ view = 'settings', context = EVENT_CONTEXT, dock = true, hidden = false, onOpen }: { view?: AppSidePanelViewName; context?: AppSidePanelContext; dock?: boolean; hidden?: boolean; onOpen?: () => void }) {
|
||||
const panel = useAppSidePanel({ view, context, dock, hidden, onOpen });
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="toolbar">{panel.toggle}</div>
|
||||
<div ref={panel.rowRef} data-testid="row">{panel.panel}</div>
|
||||
<div data-testid="phone-header"><AppSidePanelPhoneButton /></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const toggle = () => screen.getByRole('button', { name: 'plugin_panel' });
|
||||
const frame = () => screen.queryByTestId('slot-app-side-panel');
|
||||
|
||||
beforeEach(() => {
|
||||
device.isMobile = false;
|
||||
localStorage.clear();
|
||||
vi.stubGlobal('ResizeObserver', FakeResizeObserver);
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
clearOffers();
|
||||
resetPanelRequests();
|
||||
setActionDispatcher(null);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('app-side-panel', () => {
|
||||
it('shows nothing without a plugin offering it', () => {
|
||||
render(<Harness />);
|
||||
expect(screen.queryByRole('button')).toBeNull();
|
||||
expect(document.querySelector('[data-app-side-panel]')).toBeNull();
|
||||
});
|
||||
|
||||
it('opens and folds from its button, and the frame gets the view, its context and a focus request', () => {
|
||||
offerSlots('p1', ['app-side-panel'], { fill: ['app-side-panel'] });
|
||||
render(<Harness />);
|
||||
// Open until the user folds it (the plugin didn't ask to start folded).
|
||||
expect(frame()).not.toBeNull();
|
||||
expect(toggle()).toHaveAttribute('aria-pressed', 'true');
|
||||
// One name; aria-pressed says whether it is open.
|
||||
expect(toggle()).toHaveAttribute('title', 'plugin_panel');
|
||||
expect(screen.getByRole('complementary', { name: 'p1' })).toContainElement(frame());
|
||||
expect(slotProps(frame()!)).toEqual({ view: 'settings', context: EVENT_CONTEXT, focusRequest: 0 });
|
||||
expect(frame()!.getAttribute('data-fill')).toBe('true');
|
||||
|
||||
fireEvent.click(toggle());
|
||||
expect(frame()).toBeNull();
|
||||
expect(toggle()).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(toggle()).toHaveAccessibleName('plugin_panel');
|
||||
expect(localStorage.getItem(APP_SIDE_PANEL_COLLAPSED_KEY)).toBe('1');
|
||||
|
||||
// Opening asks the plugin to take the focus.
|
||||
fireEvent.click(toggle());
|
||||
expect(slotProps(frame()!).focusRequest).toBe(1);
|
||||
expect(localStorage.getItem(APP_SIDE_PANEL_COLLAPSED_KEY)).toBe('0');
|
||||
});
|
||||
|
||||
it('starts folded when the plugin asks, until the user opens it', () => {
|
||||
offerSlots('p1', ['app-side-panel'], { startFolded: ['app-side-panel'] });
|
||||
const { unmount } = render(<Harness />);
|
||||
expect(frame()).toBeNull();
|
||||
fireEvent.click(toggle());
|
||||
expect(frame()).not.toBeNull();
|
||||
unmount();
|
||||
// The choice holds in the next view.
|
||||
render(<Harness view="calendar" context={{}} />);
|
||||
expect(frame()).not.toBeNull();
|
||||
});
|
||||
|
||||
it('keeps its width between 200 and 500 px, and remembers it', () => {
|
||||
offerSlots('p1', ['app-side-panel']);
|
||||
localStorage.setItem(APP_SIDE_PANEL_WIDTH_KEY, '900');
|
||||
render(<Harness />);
|
||||
const panel = document.querySelector<HTMLElement>('[data-app-side-panel]')!;
|
||||
expect(panel.style.width).toBe('500px');
|
||||
const handle = screen.getByRole('separator');
|
||||
expect(handle).toHaveAttribute('aria-valuemin', '200');
|
||||
expect(handle).toHaveAttribute('aria-valuemax', '500');
|
||||
fireEvent.doubleClick(handle);
|
||||
expect(panel.style.width).toBe('280px');
|
||||
expect(localStorage.getItem(APP_SIDE_PANEL_WIDTH_KEY)).toBe('280');
|
||||
});
|
||||
|
||||
it('folds when the view gets too narrow, and lies over it when opened anyway', () => {
|
||||
offerSlots('p1', ['app-side-panel']);
|
||||
render(<Harness />);
|
||||
resizeRow(1200);
|
||||
expect(document.querySelector('[data-app-side-panel]')).toHaveAttribute('data-app-side-panel', 'docked');
|
||||
resizeRow(600);
|
||||
expect(frame()).toBeNull();
|
||||
fireEvent.click(toggle());
|
||||
const panel = document.querySelector<HTMLElement>('[data-app-side-panel]')!;
|
||||
expect(panel).toHaveAttribute('data-app-side-panel', 'overlay');
|
||||
expect(panel.className).toContain('absolute');
|
||||
// No drag handle over the view.
|
||||
expect(screen.queryByRole('separator')).toBeNull();
|
||||
});
|
||||
|
||||
it('answers the plugin\'s open, close and toggle requests', () => {
|
||||
offerSlots('p1', ['app-side-panel'], { startFolded: ['app-side-panel'] });
|
||||
const onOpen = vi.fn();
|
||||
render(<Harness onOpen={onOpen} />);
|
||||
expect(frame()).toBeNull();
|
||||
|
||||
let answer: boolean | null = null;
|
||||
act(() => { answer = requestPanel('p1', 'app-side-panel', 'open', undefined); });
|
||||
expect(answer).toBe(true);
|
||||
expect(frame()).not.toBeNull();
|
||||
expect(slotProps(frame()!).focusRequest).toBe(1);
|
||||
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => { answer = requestPanel('p1', 'app-side-panel', 'toggle', undefined); });
|
||||
expect(answer).toBe(false);
|
||||
expect(frame()).toBeNull();
|
||||
|
||||
act(() => { answer = requestPanel('p1', 'app-side-panel', 'close', undefined); });
|
||||
expect(answer).toBe(false);
|
||||
// The reader's panel is not this one.
|
||||
act(() => { answer = requestPanel('p1', 'email-detail-sidebar', 'open', undefined); });
|
||||
expect(answer).toBeNull();
|
||||
});
|
||||
|
||||
it('is not there in the Pro shell\'s panes', () => {
|
||||
offerSlots('p1', ['app-side-panel']);
|
||||
render(<EmbeddedContext.Provider value><Harness /></EmbeddedContext.Provider>);
|
||||
expect(screen.queryByRole('button')).toBeNull();
|
||||
expect(requestPanel('p1', 'app-side-panel', 'open', undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('asks the plugin for its dialog on a phone, with the view and what it shows', async () => {
|
||||
device.isMobile = true;
|
||||
const dispatch = vi.fn().mockResolvedValue(undefined);
|
||||
setActionDispatcher(dispatch);
|
||||
offerSlots('p1', ['app-side-panel']);
|
||||
render(<Harness view="calendar" context={{ selectedCount: 2 }} />);
|
||||
// No panel and no toolbar button; the phone header has the button.
|
||||
expect(screen.queryByTestId('toolbar')!.childElementCount).toBe(0);
|
||||
expect(document.querySelector('[data-app-side-panel]')).toBeNull();
|
||||
const phone = screen.getByRole('button', { name: 'plugin_panel_show' });
|
||||
expect(screen.getByTestId('phone-header')).toContainElement(phone);
|
||||
|
||||
fireEvent.click(phone);
|
||||
expect(dispatch).toHaveBeenCalledWith('p1', 'app-side-panel', 'open', { view: 'calendar', context: { selectedCount: 2 } });
|
||||
|
||||
// The plugin's own open request goes the same way; the panel stays closed.
|
||||
let answer: boolean | null = null;
|
||||
act(() => { answer = requestPanel('p1', 'app-side-panel', 'toggle', undefined); });
|
||||
expect(answer).toBe(false);
|
||||
expect(dispatch).toHaveBeenCalledTimes(2);
|
||||
act(() => { answer = requestPanel('p1', 'app-side-panel', 'close', undefined); });
|
||||
expect(dispatch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('uses the dialog where the view says it has no room (Settings below 1024 px)', () => {
|
||||
const dispatch = vi.fn().mockResolvedValue(undefined);
|
||||
setActionDispatcher(dispatch);
|
||||
offerSlots('p1', ['app-side-panel']);
|
||||
const { unmount } = render(<Harness dock={false} />);
|
||||
expect(screen.getByTestId('toolbar').childElementCount).toBe(0);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin_panel_show' }));
|
||||
expect(dispatch).toHaveBeenCalledWith('p1', 'app-side-panel', 'open', { view: 'settings', context: EVENT_CONTEXT });
|
||||
// Leaving the view takes the phone header's button away.
|
||||
unmount();
|
||||
render(<div><AppSidePanelPhoneButton /></div>);
|
||||
expect(screen.queryByRole('button')).toBeNull();
|
||||
});
|
||||
|
||||
it("shows no button and no panel when the plugin's shouldShow says no", async () => {
|
||||
const evaluateShouldShow = vi.fn(async () => false);
|
||||
register({
|
||||
plugin: { id: 'p2', name: 'P2', permissions: ['ui:side-panel'], managed: true },
|
||||
code: '', tier: 'untrusted', background: { evaluateShouldShow },
|
||||
slotOffers: [{ name: 'app-side-panel', order: 100, hasShouldShow: true }], hookDisposables: [],
|
||||
} as unknown as ActivePlugin);
|
||||
try {
|
||||
render(<Harness view="calendar" context={{ selectedCount: 1 }} />);
|
||||
await waitFor(() => expect(evaluateShouldShow).toHaveBeenCalledWith('app-side-panel', { view: 'calendar', context: {} }));
|
||||
expect(screen.queryByRole('button')).toBeNull();
|
||||
expect(document.querySelector('[data-app-side-panel]')).toBeNull();
|
||||
expect(requestPanel('p2', 'app-side-panel', 'open', undefined)).toBeNull();
|
||||
// It says yes: the button comes.
|
||||
evaluateShouldShow.mockResolvedValue(true);
|
||||
cleanup();
|
||||
render(<Harness view="calendar" context={{ selectedCount: 2 }} />);
|
||||
expect(await screen.findByRole('button', { name: 'plugin_panel' })).toBeTruthy();
|
||||
} finally {
|
||||
deregister('p2');
|
||||
}
|
||||
});
|
||||
|
||||
it('steps aside while the view hides it, without changing the choice the other views keep', () => {
|
||||
offerSlots('p1', ['app-side-panel']);
|
||||
const onOpen = vi.fn();
|
||||
const { rerender } = render(<Harness hidden onOpen={onOpen} />);
|
||||
expect(frame()).toBeNull();
|
||||
expect(toggle()).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(localStorage.getItem(APP_SIDE_PANEL_COLLAPSED_KEY)).toBeNull();
|
||||
rerender(<Harness hidden={false} onOpen={onOpen} />);
|
||||
expect(frame()).not.toBeNull();
|
||||
// Opening it while hidden asks the view to make room.
|
||||
rerender(<Harness hidden onOpen={onOpen} />);
|
||||
fireEvent.click(toggle());
|
||||
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('grows only as far as the view keeps its room, so resizing never folds it', () => {
|
||||
offerSlots('p1', ['app-side-panel']);
|
||||
render(<Harness />);
|
||||
resizeRow(900);
|
||||
const handle = screen.getByRole('separator');
|
||||
expect(handle).toHaveAttribute('aria-valuemax', '420');
|
||||
handle.focus();
|
||||
for (let i = 0; i < 30; i++) fireEvent.keyDown(handle, { key: 'ArrowLeft' });
|
||||
const panel = document.querySelector<HTMLElement>('[data-app-side-panel]')!;
|
||||
expect(panel).toHaveAttribute('data-app-side-panel', 'docked');
|
||||
expect(panel.style.width).toBe('420px');
|
||||
// The handle is still there and keeps the focus.
|
||||
expect(document.activeElement).toBe(handle);
|
||||
// A drag stops at the same place.
|
||||
fireEvent.pointerDown(handle, { clientX: 500, pointerId: 1, button: 0 });
|
||||
fireEvent.pointerMove(document, { clientX: 100, pointerId: 1 });
|
||||
fireEvent.pointerUp(document, { clientX: 100, pointerId: 1 });
|
||||
expect(document.querySelector('[data-app-side-panel]')).toHaveAttribute('data-app-side-panel', 'docked');
|
||||
expect(document.querySelector<HTMLElement>('[data-app-side-panel]')!.style.width).toBe('420px');
|
||||
});
|
||||
|
||||
it('gives the focus back to its button when it closes with the focus inside', async () => {
|
||||
offerSlots('p1', ['app-side-panel']);
|
||||
render(<Harness />);
|
||||
const panel = screen.getByRole('complementary');
|
||||
panel.tabIndex = -1;
|
||||
panel.focus();
|
||||
act(() => { requestPanel('p1', 'app-side-panel', 'close', undefined); });
|
||||
await waitFor(() => expect(document.activeElement).toBe(toggle()));
|
||||
});
|
||||
|
||||
it("names the plugin's panel shortcut in the button's title", () => {
|
||||
offerSlots('p1', ['app-side-panel']);
|
||||
const dispose = registerShortcuts({ pluginId: 'p1', invokeHook: vi.fn() } as never, [{ id: 'toggle-panel', keys: 'Alt+A', label: 'Toggle' }]);
|
||||
try {
|
||||
render(<Harness />);
|
||||
expect(toggle()).toHaveAttribute('title', 'plugin_panel (Alt+A)');
|
||||
expect(toggle()).toHaveAttribute('aria-keyshortcuts', 'Alt+A');
|
||||
expect(toggle()).toHaveAccessibleName('plugin_panel');
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
|
||||
// A plugin dialog that asks to be filled (api.ui.openDialog({ fill: true })):
|
||||
// its frame takes the dialog's height, which follows what the window shows,
|
||||
// so a chat's input stays in view on a phone with the keyboard up.
|
||||
|
||||
vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
|
||||
vi.mock('../plugin-iframe-slot', () => ({
|
||||
PluginIframeSlot: ({ fill }: { fill?: boolean }) => <div data-testid="custom-frame" data-fill={fill ? 'true' : 'false'} />,
|
||||
}));
|
||||
|
||||
import { PluginDialogHost } from '../plugin-dialog-host';
|
||||
import { awaitCustomDialog, head, resetDialogLimits, resolveHead } from '@/lib/plugin-sandbox/host-dialog';
|
||||
import { isFillableSlot } from '@/lib/plugin-sandbox/slot-layout';
|
||||
|
||||
let listeners: (() => void)[] = [];
|
||||
const viewport = { height: 667, addEventListener: (_: string, fn: () => void) => { listeners.push(fn); }, removeEventListener: () => {} };
|
||||
|
||||
beforeEach(() => {
|
||||
while (head()) resolveHead(null);
|
||||
resetDialogLimits();
|
||||
listeners = [];
|
||||
vi.stubGlobal('visualViewport', viewport);
|
||||
viewport.height = 667;
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
const box = () => document.querySelector<HTMLElement>('[data-plugin-dialog]')!;
|
||||
|
||||
describe('a plugin dialog that fills', () => {
|
||||
it('fills the dialog, which fits the visible window and follows the keyboard', () => {
|
||||
expect(isFillableSlot('plugin-dialog')).toBe(true);
|
||||
render(<PluginDialogHost />);
|
||||
act(() => { void awaitCustomDialog({ pluginId: 'ai', title: 'AI assistant', message: '', slot: 'plugin-dialog', fill: true }); });
|
||||
expect(screen.getByTestId('custom-frame').dataset.fill).toBe('true');
|
||||
// 667 px window: 90 % less the margin, at most 640.
|
||||
expect(box().style.height).toBe('584px');
|
||||
// The keyboard comes up: 360 px left.
|
||||
act(() => { viewport.height = 360; for (const fn of listeners) fn(); });
|
||||
expect(box().style.height).toBe('308px');
|
||||
act(() => resolveHead(null));
|
||||
});
|
||||
|
||||
it('grows with its content as before without fill', () => {
|
||||
render(<PluginDialogHost />);
|
||||
act(() => { void awaitCustomDialog({ pluginId: 'ai', title: 'AI assistant', message: '', slot: 'plugin-dialog' }); });
|
||||
expect(screen.getByTestId('custom-frame').dataset.fill).toBe('false');
|
||||
expect(box().style.height).toBe('');
|
||||
expect(box().style.maxHeight).toBe('90vh');
|
||||
act(() => resolveHead(null));
|
||||
});
|
||||
|
||||
it('is what api.ui.openDialog asks for with fill: true', async () => {
|
||||
const { dispatchApiCall } = await import('@/lib/plugin-sandbox/host-api');
|
||||
const plugin = { id: 'ai', name: 'AI', permissions: [], managed: true } as never;
|
||||
void dispatchApiCall(plugin, 'ui.openDialog', [{ slot: 'plugin-dialog', title: 'AI assistant', fill: true }]);
|
||||
await vi.waitFor(() => expect(head()?.fill).toBe(true));
|
||||
resolveHead(null);
|
||||
void dispatchApiCall(plugin, 'ui.openDialog', [{ slot: 'plugin-dialog', title: 'AI assistant' }]);
|
||||
await vi.waitFor(() => expect(head()).toBeTruthy());
|
||||
expect(head()?.fill).toBeUndefined();
|
||||
resolveHead(null);
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -0,0 +1,367 @@
|
||||
'use client';
|
||||
|
||||
// The plugin side panel of Calendar, Contacts, Files and Settings (slot
|
||||
// app-side-panel), built like the reader's (email-viewer.tsx): one button at
|
||||
// the end of the view's h-14 toolbar row shows and hides it, it can be
|
||||
// dragged between 200 and 500 px, and it lies over the view instead of
|
||||
// squeezing it when the view would get too narrow. Its width and whether it
|
||||
// is folded are kept in localStorage for all four views. Until the user
|
||||
// folds or opens it, a plugin that asked to start folded starts folded.
|
||||
// Only a plugin whose shouldShow says yes gets the button and the panel.
|
||||
//
|
||||
// Each view mounts its own panel, so the frame starts over when the user
|
||||
// switches views; the plugin keeps what it needs in its storage. In the Pro
|
||||
// shell's panes there is none (only a Mail pane's reader panel).
|
||||
//
|
||||
// Phones, and Settings below 1024 px (phone-shaped there), have no room for
|
||||
// a panel: a button in the phone header (PhoneHeaderActions, and Contacts'
|
||||
// row over an open contact) asks the plugin for its dialog instead, through
|
||||
// its onAction hook (surface `app-side-panel`, actionId `open`, context
|
||||
// { view, context }), and so does api.ui.openPanel there.
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { PanelRightClose, PanelRightOpen } from '@/components/icons';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { IconButton } from '@/components/ui/icon-button';
|
||||
import { ResizeHandle } from '@/components/layout/resize-handle';
|
||||
import { PluginSlot } from '@/components/plugins/plugin-slot';
|
||||
import { usePluginSlotOffers } from '@/hooks/use-plugin-slot-offers';
|
||||
import { useIsEmbedded } from '@/hooks/use-is-embedded';
|
||||
import { useIsMobile } from '@/hooks/use-media-query';
|
||||
import { onPanelRequest } from '@/lib/plugin-sandbox/panel-requests';
|
||||
import { runPluginAction } from '@/lib/plugin-sandbox/actions';
|
||||
import { get as getActivePlugin } from '@/lib/plugin-sandbox/registry';
|
||||
import { slotPropsFor } from '@/lib/plugin-sandbox/permissions';
|
||||
import { shortcutKeysFor } from '@/lib/plugin-sandbox/shortcuts';
|
||||
import type { AppSidePanelContext, AppSidePanelViewName } from '@/lib/plugin-sandbox/assistant/f.types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export const APP_SIDE_PANEL_SLOT = 'app-side-panel';
|
||||
export const APP_SIDE_PANEL_MIN_WIDTH = 200;
|
||||
export const APP_SIDE_PANEL_MAX_WIDTH = 500;
|
||||
export const APP_SIDE_PANEL_DEFAULT_WIDTH = 280;
|
||||
/** The view keeps at least this much room next to a docked panel; with less the panel lies over it. */
|
||||
export const APP_SIDE_PANEL_MIN_CONTENT = 480;
|
||||
export const APP_SIDE_PANEL_COLLAPSED_KEY = 'appSidePanelCollapsed';
|
||||
export const APP_SIDE_PANEL_WIDTH_KEY = 'appSidePanelWidth';
|
||||
/** The id of a plugin shortcut that opens and closes its panel; its keys go into the button's title. */
|
||||
export const PANEL_SHORTCUT_ID = 'toggle-panel';
|
||||
|
||||
const clampWidth = (n: number) => Math.max(APP_SIDE_PANEL_MIN_WIDTH, Math.min(APP_SIDE_PANEL_MAX_WIDTH, n));
|
||||
|
||||
/**
|
||||
* The button that shows and hides a plugin side panel, the same in the
|
||||
* reader and in every other view: at the end of the toolbar row above the
|
||||
* panel. One name ("Plugin panel"); aria-pressed says whether it is open.
|
||||
* `shortcut`: the keys of the plugin's own shortcut for its panel.
|
||||
*/
|
||||
export function PluginPanelToggle({ open, onClick, className, buttonRef, shortcut }: {
|
||||
open: boolean;
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
buttonRef?: React.Ref<HTMLButtonElement>;
|
||||
shortcut?: string | null;
|
||||
}) {
|
||||
const t = useTranslations('email_viewer');
|
||||
const label = t('plugin_panel');
|
||||
return (
|
||||
<Button
|
||||
ref={buttonRef}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClick}
|
||||
className={cn('h-8 w-8 shrink-0', className)}
|
||||
title={shortcut ? `${label} (${shortcut})` : label}
|
||||
aria-label={label}
|
||||
aria-pressed={open}
|
||||
aria-keyshortcuts={shortcut ?? undefined}
|
||||
data-plugin-panel-toggle=""
|
||||
>
|
||||
{open ? <PanelRightClose className="w-4 h-4 text-muted-foreground" /> : <PanelRightOpen className="w-4 h-4 text-muted-foreground" />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
/** The keys of the panel shortcut of the first of these plugins that has one ("Alt+A"), or null. */
|
||||
export function panelShortcutOf(pluginIds: string[]): string | null {
|
||||
for (const id of pluginIds) {
|
||||
const keys = shortcutKeysFor(id, PANEL_SHORTCUT_ID);
|
||||
if (keys) return keys;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The panel's name for screen readers: its plugins' names, else "Plugin panel". */
|
||||
export function panelLabelOf(pluginIds: string[], fallback: string): string {
|
||||
const names = pluginIds.map((id) => getActivePlugin(id)?.plugin.name).filter((n): n is string => !!n);
|
||||
return names.length ? names.join(', ') : fallback;
|
||||
}
|
||||
|
||||
// ─── Where the panel can't dock: the plugin's dialog ───────────────
|
||||
|
||||
export interface AppSidePanelDialogEntry {
|
||||
pluginId: string;
|
||||
view: AppSidePanelViewName;
|
||||
context: AppSidePanelContext;
|
||||
}
|
||||
|
||||
let dialogEntry: AppSidePanelDialogEntry | null = null;
|
||||
const dialogListeners = new Set<() => void>();
|
||||
|
||||
function setDialogEntry(entry: AppSidePanelDialogEntry | null) {
|
||||
dialogEntry = entry;
|
||||
for (const listener of dialogListeners) listener();
|
||||
}
|
||||
|
||||
function subscribeDialogEntry(listener: () => void) {
|
||||
dialogListeners.add(listener);
|
||||
return () => { dialogListeners.delete(listener); };
|
||||
}
|
||||
|
||||
/** The view whose plugin panel opens as a dialog right now (a phone), or null. */
|
||||
export function useAppSidePanelDialogEntry(): AppSidePanelDialogEntry | null {
|
||||
return useSyncExternalStore(subscribeDialogEntry, () => dialogEntry, () => null);
|
||||
}
|
||||
|
||||
/** Asks the plugin for its dialog with what the view shows (the context is cut to its permissions on the way). */
|
||||
export function openAppSidePanelDialog(entry: AppSidePanelDialogEntry, pluginId = entry.pluginId): void {
|
||||
void runPluginAction(pluginId, APP_SIDE_PANEL_SLOT, 'open', { view: entry.view, context: entry.context }).catch(() => {});
|
||||
}
|
||||
|
||||
/** The phone's button for the view's plugin panel, which opens as a dialog there. */
|
||||
export function AppSidePanelPhoneButton({ className }: { className?: string } = {}) {
|
||||
const t = useTranslations('email_viewer');
|
||||
const entry = useAppSidePanelDialogEntry();
|
||||
if (!entry) return null;
|
||||
return (
|
||||
<IconButton
|
||||
size="icon-lg"
|
||||
label={t('plugin_panel_show')}
|
||||
onClick={() => openAppSidePanelDialog(entry)}
|
||||
className={className}
|
||||
data-app-side-panel-phone=""
|
||||
>
|
||||
<PanelRightOpen />
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Which plugins show it ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The plugins whose offer of the panel shows here: each one's shouldShow
|
||||
* (asked in its background frame with the props it may see) said yes, or it
|
||||
* has none. Until a plugin answers its offer doesn't count, so a plugin that
|
||||
* is switched off for these views never gets a button that opens nothing.
|
||||
*/
|
||||
function useShownPluginIds(pluginOffers: readonly { pluginId: string; hasShouldShow: boolean }[], view: AppSidePanelViewName, context: AppSidePanelContext): string[] {
|
||||
const [answers, setAnswers] = useState<Record<string, boolean>>({});
|
||||
const key = pluginOffers.map((o) => `${o.pluginId}:${o.hasShouldShow ? 1 : 0}`).join(',');
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
for (const offer of pluginOffers) {
|
||||
const active = getActivePlugin(offer.pluginId);
|
||||
if (!offer.hasShouldShow || !active?.background?.evaluateShouldShow) {
|
||||
setAnswers((prev) => (prev[offer.pluginId] === true ? prev : { ...prev, [offer.pluginId]: true }));
|
||||
continue;
|
||||
}
|
||||
const props = slotPropsFor(active.plugin, APP_SIDE_PANEL_SLOT, { view, context });
|
||||
active.background.evaluateShouldShow(APP_SIDE_PANEL_SLOT, props)
|
||||
.then((show) => { if (live) setAnswers((prev) => (prev[offer.pluginId] === show ? prev : { ...prev, [offer.pluginId]: show })); })
|
||||
.catch(() => { if (live) setAnswers((prev) => ({ ...prev, [offer.pluginId]: false })); });
|
||||
}
|
||||
return () => { live = false; };
|
||||
// The offers by their key; a new context asks again (the plugin may decide by it).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key, view, context]);
|
||||
return useMemo(() => pluginOffers.filter((o) => answers[o.pluginId] === true).map((o) => o.pluginId), [pluginOffers, answers]);
|
||||
}
|
||||
|
||||
// ─── The panel ─────────────────────────────────────────────────────
|
||||
|
||||
export interface UseAppSidePanelOptions {
|
||||
view: AppSidePanelViewName;
|
||||
/** What the view shows; keep it memoised, a new object is sent to the plugin. */
|
||||
context: AppSidePanelContext;
|
||||
/** False where the view has no room for a panel at any width it is in now (Settings below 1024 px): the dialog then. */
|
||||
dock?: boolean;
|
||||
/**
|
||||
* Folded here for now without changing the stored choice (Files while its
|
||||
* details panel is open), so the other views keep the panel as the user left it.
|
||||
*/
|
||||
hidden?: boolean;
|
||||
/** Called when the panel opens, so the view can fold a panel it shares the place with (Files' details). */
|
||||
onOpen?: () => void;
|
||||
}
|
||||
|
||||
export interface AppSidePanel {
|
||||
/** A plugin shows the panel here (outside the Pro shell's panes). */
|
||||
offered: boolean;
|
||||
/** The panel docks in this view now (else it is a dialog, or nothing). */
|
||||
docks: boolean;
|
||||
/** Whether the docked panel is open. */
|
||||
open: boolean;
|
||||
/** Whether the user's choice is to have it open (also while `hidden` folds it here). */
|
||||
wantsOpen: boolean;
|
||||
/** The button for the end of the toolbar row, or null. */
|
||||
toggle: ReactNode;
|
||||
/** The resize handle and the panel, as the last children of the row it sits in; null while folded. */
|
||||
panel: ReactNode;
|
||||
/** Put on the row the panel sits in: its width decides docked or over the view. */
|
||||
rowRef: (el: HTMLElement | null) => void;
|
||||
/** Folds the panel and keeps it folded. */
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
function readChoice(): boolean | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
const stored = localStorage.getItem(APP_SIDE_PANEL_COLLAPSED_KEY);
|
||||
return stored === '1' ? true : stored === '0' ? false : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function readWidth(): number {
|
||||
if (typeof window === 'undefined') return APP_SIDE_PANEL_DEFAULT_WIDTH;
|
||||
try {
|
||||
const n = parseInt(localStorage.getItem(APP_SIDE_PANEL_WIDTH_KEY) ?? '', 10);
|
||||
return Number.isFinite(n) ? clampWidth(n) : APP_SIDE_PANEL_DEFAULT_WIDTH;
|
||||
} catch { return APP_SIDE_PANEL_DEFAULT_WIDTH; }
|
||||
}
|
||||
|
||||
export function useAppSidePanel({ view, context, dock = true, hidden = false, onOpen }: UseAppSidePanelOptions): AppSidePanel {
|
||||
const t = useTranslations('email_viewer');
|
||||
const isEmbedded = useIsEmbedded();
|
||||
const isMobile = useIsMobile();
|
||||
const offers = usePluginSlotOffers(APP_SIDE_PANEL_SLOT);
|
||||
const shownIds = useShownPluginIds(isEmbedded ? [] : offers, view, context);
|
||||
const offered = shownIds.length > 0 && !isEmbedded;
|
||||
const docks = offered && dock && !isMobile;
|
||||
const asDialog = offered && !docks;
|
||||
|
||||
const [choice, setChoice] = useState<boolean | null>(readChoice);
|
||||
const [width, setWidth] = useState<number>(readWidth);
|
||||
const widthAtStart = useRef(width);
|
||||
const [forcedOpen, setForcedOpen] = useState(false);
|
||||
const [focusRequest, setFocusRequest] = useState(0);
|
||||
const [rowEl, setRowEl] = useState<HTMLElement | null>(null);
|
||||
const [rowWidth, setRowWidth] = useState(0);
|
||||
const toggleRef = useRef<HTMLButtonElement | null>(null);
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!docks || !rowEl || typeof ResizeObserver === 'undefined') return;
|
||||
const ro = new ResizeObserver((entries) => setRowWidth(Math.round(entries[0]?.contentRect.width ?? 0)));
|
||||
ro.observe(rowEl);
|
||||
return () => ro.disconnect();
|
||||
}, [docks, rowEl]);
|
||||
|
||||
const collapsed = choice ?? offers.some((o) => o.startFolded && shownIds.includes(o.pluginId));
|
||||
const tooNarrow = rowWidth > 0 && rowWidth - width < APP_SIDE_PANEL_MIN_CONTENT;
|
||||
const folded = collapsed || hidden || (tooNarrow && !forcedOpen);
|
||||
const overlay = !folded && tooNarrow;
|
||||
// Docked, the panel grows only as far as the view keeps its room: a drag
|
||||
// or the arrow keys stop there instead of folding the panel away.
|
||||
const dockedMax = rowWidth > 0 ? Math.max(APP_SIDE_PANEL_MIN_WIDTH, rowWidth - APP_SIDE_PANEL_MIN_CONTENT) : APP_SIDE_PANEL_MAX_WIDTH;
|
||||
|
||||
useEffect(() => {
|
||||
if (choice === null) return;
|
||||
try { localStorage.setItem(APP_SIDE_PANEL_COLLAPSED_KEY, choice ? '1' : '0'); } catch { /* ignore */ }
|
||||
}, [choice]);
|
||||
useEffect(() => {
|
||||
try { localStorage.setItem(APP_SIDE_PANEL_WIDTH_KEY, String(width)); } catch { /* ignore */ }
|
||||
}, [width]);
|
||||
|
||||
const onOpenRef = useRef(onOpen);
|
||||
onOpenRef.current = onOpen;
|
||||
const state = useRef({ offered, docks, folded, tooNarrow, view, context, pluginId: shownIds[0] ?? null });
|
||||
state.current = { offered, docks, folded, tooNarrow, view, context, pluginId: shownIds[0] ?? null };
|
||||
|
||||
const setOpen = useCallback((want: boolean) => {
|
||||
const now = state.current;
|
||||
if (want) {
|
||||
setChoice(false);
|
||||
if (now.tooNarrow) setForcedOpen(true);
|
||||
// Opening on request puts the focus in the panel (the plugin's input).
|
||||
setFocusRequest((n) => n + 1);
|
||||
onOpenRef.current?.();
|
||||
} else {
|
||||
// Focus in the panel goes back to the button, not to the page's start.
|
||||
const hadFocus = !!panelRef.current && panelRef.current.contains(document.activeElement);
|
||||
setChoice(true);
|
||||
setForcedOpen(false);
|
||||
if (hadFocus) requestAnimationFrame(() => toggleRef.current?.focus());
|
||||
}
|
||||
}, []);
|
||||
|
||||
const close = useCallback(() => {
|
||||
if (!state.current.folded) setOpen(false);
|
||||
}, [setOpen]);
|
||||
|
||||
// api.ui.openPanel / closePanel / togglePanel for this slot.
|
||||
useEffect(() => onPanelRequest((request) => {
|
||||
if (request.slot !== APP_SIDE_PANEL_SLOT) return null;
|
||||
const now = state.current;
|
||||
if (!now.offered) return null;
|
||||
if (!now.docks) {
|
||||
// No room for a panel: the plugin that asked gets the open request
|
||||
// for its dialog, and the panel itself stays closed.
|
||||
if (request.mode !== 'close') openAppSidePanelDialog({ pluginId: request.pluginId, view: now.view, context: now.context });
|
||||
return false;
|
||||
}
|
||||
const want = request.mode === 'open' ? true : request.mode === 'close' ? false : now.folded;
|
||||
setOpen(want);
|
||||
return want;
|
||||
}), [setOpen]);
|
||||
|
||||
// The phone button, while this view's panel is a dialog.
|
||||
const firstPluginId = shownIds[0] ?? null;
|
||||
useEffect(() => {
|
||||
if (!asDialog || !firstPluginId) return;
|
||||
setDialogEntry({ pluginId: firstPluginId, view, context });
|
||||
return () => setDialogEntry(null);
|
||||
}, [asDialog, firstPluginId, view, context]);
|
||||
|
||||
const toggle = docks ? (
|
||||
<PluginPanelToggle open={!folded} onClick={() => setOpen(folded)} buttonRef={toggleRef} shortcut={panelShortcutOf(shownIds)} />
|
||||
) : null;
|
||||
|
||||
const panel = docks && !folded ? (
|
||||
<>
|
||||
{!overlay && (
|
||||
<ResizeHandle
|
||||
pane="sidePanel"
|
||||
value={width}
|
||||
min={APP_SIDE_PANEL_MIN_WIDTH}
|
||||
max={Math.min(APP_SIDE_PANEL_MAX_WIDTH, dockedMax)}
|
||||
onResizeStart={() => { widthAtStart.current = width; }}
|
||||
// The panel is on the right: dragging left makes it wider.
|
||||
onResize={(delta) => setWidth(clampWidth(Math.min(dockedMax, widthAtStart.current - delta)))}
|
||||
onResizeEnd={() => { widthAtStart.current = width; }}
|
||||
onDoubleClick={() => setWidth(Math.min(dockedMax, APP_SIDE_PANEL_DEFAULT_WIDTH))}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="complementary"
|
||||
aria-label={panelLabelOf(shownIds, t('plugin_panel'))}
|
||||
data-app-side-panel={overlay ? 'overlay' : 'docked'}
|
||||
data-app-side-panel-view={view}
|
||||
className={cn(
|
||||
'flex flex-col h-full border-s border-border bg-background overflow-hidden shrink-0',
|
||||
overlay && 'absolute inset-y-0 end-0 z-30 shadow-xl',
|
||||
)}
|
||||
style={overlay
|
||||
? { width: Math.min(width, Math.max(APP_SIDE_PANEL_MIN_WIDTH, rowWidth - 48)) }
|
||||
: { width, minWidth: width }}
|
||||
>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<PluginSlot name="app-side-panel" extraProps={{ view, context, focusRequest }} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null;
|
||||
|
||||
return { offered, docks, open: docks && !folded, wantsOpen: docks && !collapsed, toggle, panel, rowRef: setRowEl, close };
|
||||
}
|
||||
@@ -118,6 +118,7 @@ function PluginDialogView({ current, layer }: { current: DialogRequest; layer: '
|
||||
// Footer buttons of a custom dialog: the plugin declares them, the host
|
||||
// draws them and passes each click back as `dialogButtonClick` ({ id, n }).
|
||||
const [buttons, setButtons] = useState<DialogButtonSpec[]>([]);
|
||||
const viewportHeight = useVisibleHeight(current?.kind === 'custom' && current.fill === true);
|
||||
const [buttonClick, setButtonClick] = useState<{ id: string; n: number }>({ id: '', n: 0 });
|
||||
const setDialogButtons = useCallback((value: unknown) => { setButtons(readDialogButtons(value)); }, []);
|
||||
const pressButton = (id: string) => setButtonClick((c) => ({ id, n: c.n + 1 }));
|
||||
@@ -220,7 +221,10 @@ function PluginDialogView({ current, layer }: { current: DialogRequest; layer: '
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="flex w-full flex-col overflow-hidden rounded-lg border border-border bg-background text-foreground shadow-xl animate-in zoom-in-95 duration-200"
|
||||
style={{ maxWidth: custom ? (current.width ?? 560) : 448, maxHeight: '90vh' }}
|
||||
style={custom && current.fill
|
||||
// As tall as the visible window lets it be (a phone's keyboard takes its share).
|
||||
? { maxWidth: current.width ?? 560, height: Math.min(640, Math.round(viewportHeight * 0.9) - 16) }
|
||||
: { maxWidth: custom ? (current.width ?? 560) : 448, maxHeight: '90vh' }}
|
||||
data-plugin-dialog={current.pluginId}
|
||||
>
|
||||
{custom ? (
|
||||
@@ -240,12 +244,13 @@ function PluginDialogView({ current, layer }: { current: DialogRequest; layer: '
|
||||
<X className="w-5 h-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-4">
|
||||
<div className={cn('min-h-0 flex-1 px-6 py-4', current.fill ? 'flex flex-col overflow-hidden' : 'overflow-y-auto')}>
|
||||
{message}
|
||||
<PluginIframeSlot
|
||||
pluginId={current.pluginId}
|
||||
slot={(current.slot ?? 'plugin-dialog') as SlotName}
|
||||
extraProps={customProps}
|
||||
fill={current.fill === true}
|
||||
/>
|
||||
</div>
|
||||
{buttons.length > 0 && (
|
||||
@@ -329,6 +334,21 @@ function PluginDialogView({ current, layer }: { current: DialogRequest; layer: '
|
||||
}
|
||||
|
||||
/** A footer button's icon by name; unknown names show none. */
|
||||
/** The height of what the window shows now (the visual viewport, without a phone's keyboard), followed while `on`. */
|
||||
function useVisibleHeight(on: boolean): number {
|
||||
const read = () => (typeof window === 'undefined' ? 800 : Math.round(window.visualViewport?.height ?? window.innerHeight));
|
||||
const [height, setHeight] = useState(read);
|
||||
useEffect(() => {
|
||||
if (!on || typeof window === 'undefined') return;
|
||||
const update = () => setHeight(read());
|
||||
update();
|
||||
const target = window.visualViewport ?? window;
|
||||
target.addEventListener('resize', update);
|
||||
return () => target.removeEventListener('resize', update);
|
||||
}, [on]);
|
||||
return height;
|
||||
}
|
||||
|
||||
function FooterIcon({ name }: { name?: string }) {
|
||||
const Glyph = name ? iconForName(name) : null;
|
||||
return Glyph ? <Glyph className="w-4 h-4" aria-hidden="true" /> : null;
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import React from 'react';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { InstalledPlugin } from '@/lib/plugin-types';
|
||||
import { framePropsOf } from '@/components/plugins/__tests__/fake-slot-offer';
|
||||
|
||||
// The plugin side panel (app-side-panel) in Settings: on desktop an h-14 row
|
||||
// over the page and the panel with the page's name and the button at its
|
||||
// end; below 1024 px Settings is phone-shaped and the panel is a dialog.
|
||||
|
||||
const createSlotInstance = vi.fn((..._args: unknown[]) => ({ destroy: vi.fn(), updateProps: vi.fn(), setTheme: vi.fn() }));
|
||||
vi.mock('@/components/plugins/plugin-iframe-slot', () => import('@/components/plugins/__tests__/fake-slot-offer'));
|
||||
vi.mock('@/lib/plugin-sandbox/host-bridge', () => ({
|
||||
createSlotInstance: (...args: unknown[]) => createSlotInstance(...args),
|
||||
}));
|
||||
vi.mock('@/lib/plugin-sandbox/host-theme', () => ({ snapshotHostTheme: () => ({}) }));
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: (ns?: string) => (key: string) => (ns ? `${ns}.${key}` : key),
|
||||
useMessages: () => ({}),
|
||||
useLocale: () => 'en',
|
||||
useFormatter: () => ({ dateTime: String, relativeTime: String, number: String }),
|
||||
}));
|
||||
const media = vi.hoisted(() => ({ desktop: true }));
|
||||
vi.mock('@/hooks/use-media-query', () => ({
|
||||
useIsDesktop: () => media.desktop,
|
||||
useIsMobile: () => false,
|
||||
useMediaQuery: () => false,
|
||||
useDeviceDetection: () => ({ isMobile: false, isTablet: !media.desktop, isDesktop: media.desktop }),
|
||||
}));
|
||||
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail', stalwartFeaturesEnabled: false }) }));
|
||||
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
|
||||
vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null }));
|
||||
vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null }));
|
||||
vi.mock('@/components/settings/appearance-settings', () => ({ AppearanceSettings: () => <div>appearance pane</div> }));
|
||||
vi.mock('@/components/settings/accessibility-settings', () => ({ AccessibilitySettings: () => <div>accessibility pane</div> }));
|
||||
|
||||
const { SettingsApp } = await import('../settings-app');
|
||||
const registry = await import('@/lib/plugin-sandbox/registry');
|
||||
const { useAuthStore } = await import('@/stores/auth-store');
|
||||
const { usePluginStore } = await import('@/stores/plugin-store');
|
||||
const { useThemeStore } = await import('@/stores/theme-store');
|
||||
|
||||
function activate(id: string, name: string, slots: string[]) {
|
||||
const plugin: InstalledPlugin = {
|
||||
id, name, version: '1.0.0', author: '', description: '', type: 'ui-extension',
|
||||
permissions: ['ui:settings-section', 'ui:side-panel'], grantedPermissions: ['ui:settings-section', 'ui:side-panel'], entrypoint: 'index.js',
|
||||
enabled: true, status: 'running', settings: {},
|
||||
};
|
||||
registry.register({
|
||||
plugin, code: '', tier: 'untrusted',
|
||||
background: { evaluateShouldShow: vi.fn(async () => true) } as never,
|
||||
slotOffers: slots.map((slot) => ({ name: slot as never, order: 100, hasShouldShow: false })),
|
||||
hookDisposables: [],
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
media.desktop = true;
|
||||
createSlotInstance.mockClear();
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
|
||||
try { localStorage.setItem('settings-active-tab', 'appearance'); } catch { /* ignore */ }
|
||||
// Every capability check answers "no"; ids are plain strings.
|
||||
const client = new Proxy({}, {
|
||||
get: (_t, prop) => (prop === 'then' ? undefined : (prop === 'getAccountId' ? () => 'acc' : () => null)),
|
||||
});
|
||||
useAuthStore.setState({ isAuthenticated: true, isLoading: false, client: client as never });
|
||||
usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) });
|
||||
useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) } as never);
|
||||
});
|
||||
afterEach(() => {
|
||||
for (const p of registry.all()) registry.deregister(p.plugin.id);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const { setActionDispatcher } = await import('@/lib/plugin-sandbox/actions');
|
||||
|
||||
describe('Settings side panel', () => {
|
||||
it('adds no row without a plugin', () => {
|
||||
render(<SettingsApp linkSegments={[]} />);
|
||||
expect(document.querySelector('[data-settings-panel-row]')).toBeNull();
|
||||
});
|
||||
|
||||
it('puts the page name and the button in an h-14 row over the page and the panel', () => {
|
||||
activate('assistant', 'Assistant', ['app-side-panel']);
|
||||
render(<SettingsApp linkSegments={[]} />);
|
||||
const row = document.querySelector<HTMLElement>('[data-settings-panel-row]')!;
|
||||
expect(row.className).toContain('h-14');
|
||||
const toggle = row.querySelector<HTMLElement>('[data-plugin-panel-toggle]')!;
|
||||
expect(row.lastElementChild).toBe(toggle);
|
||||
const frame = screen.getByTestId('plugin-frame-app-side-panel');
|
||||
const page = (framePropsOf(frame).context as { page: { id: string; label: string } }).page;
|
||||
expect(page.id).toBe('appearance');
|
||||
expect(row.firstElementChild!.textContent).toBe(page.label);
|
||||
expect(page.label).not.toBe('');
|
||||
// The page itself (main) and then the panel, in the row under it.
|
||||
const panel = document.querySelector<HTMLElement>('[data-app-side-panel]')!;
|
||||
expect(panel.parentElement!.lastElementChild).toBe(panel);
|
||||
expect(panel.parentElement!.querySelector('main, [role=main]')).not.toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByTestId('settings-tab-accessibility'));
|
||||
expect((framePropsOf(screen.getByTestId('plugin-frame-app-side-panel')).context as { page: { id: string } }).page.id).toBe('accessibility');
|
||||
});
|
||||
|
||||
it('asks for the dialog below 1024 px, from the phone header', () => {
|
||||
media.desktop = false;
|
||||
const dispatch = vi.fn().mockResolvedValue(undefined);
|
||||
setActionDispatcher(dispatch);
|
||||
activate('assistant', 'Assistant', ['app-side-panel']);
|
||||
render(<SettingsApp linkSegments={[]} />);
|
||||
expect(document.querySelector('[data-settings-panel-row]')).toBeNull();
|
||||
expect(document.querySelector('[data-app-side-panel]')).toBeNull();
|
||||
const buttons = document.querySelectorAll<HTMLElement>('[data-app-side-panel-phone]');
|
||||
expect(buttons.length).toBeGreaterThan(0);
|
||||
fireEvent.click(buttons[0]);
|
||||
expect(dispatch).toHaveBeenCalledWith('assistant', 'app-side-panel', 'open', expect.objectContaining({ view: 'settings' }));
|
||||
setActionDispatcher(null);
|
||||
});
|
||||
|
||||
it('keeps the page (and what was typed on it) when the panel starts docking', () => {
|
||||
render(<SettingsApp linkSegments={[]} />);
|
||||
const pane = screen.getByText('appearance pane');
|
||||
act(() => activate('assistant', 'Assistant', ['app-side-panel']));
|
||||
expect(document.querySelector('[data-settings-panel-row]')).not.toBeNull();
|
||||
expect(pane.isConnected).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -75,6 +75,7 @@ import { useManagedAccountStore } from '@/stores/managed-account-store';
|
||||
import { useIsDesktop } from '@/hooks/use-media-query';
|
||||
import { NavigationRail } from '@/components/layout/navigation-rail';
|
||||
import { PhoneHeaderActions } from '@/components/layout/phone-header-actions';
|
||||
import { useAppSidePanel } from '@/components/plugins/app-side-panel';
|
||||
import { SidebarAppsModal } from '@/components/layout/sidebar-apps-modal';
|
||||
import { InlineAppView } from '@/components/layout/inline-app-view';
|
||||
import { useSidebarApps } from '@/hooks/use-sidebar-apps';
|
||||
@@ -803,6 +804,15 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
|
||||
// "<Page> - Settings - <account> - <app>" (WCAG 2.4.2). On a phone the
|
||||
// page list alone, with no page open, is just Settings.
|
||||
const activeTabLabel = visibleTabs.find((tab) => tab.id === effectiveActiveTab)?.label ?? '';
|
||||
|
||||
// The plugin side panel (app-side-panel), with the open page. Docked on
|
||||
// desktop under a row with the page's name and its button; below 1024 px
|
||||
// Settings is phone-shaped and the panel opens as a dialog.
|
||||
const sidePanelContext = useMemo(
|
||||
() => ({ page: { id: String(effectiveActiveTab), label: activeTabLabel } }),
|
||||
[effectiveActiveTab, activeTabLabel],
|
||||
);
|
||||
const sidePanel = useAppSidePanel({ view: 'settings', context: sidePanelContext, dock: isDesktop });
|
||||
useDocumentTitle(
|
||||
activeTabLabel && (isDesktop || mobileShowContent)
|
||||
? `${activeTabLabel} - ${tSidebar('settings')}`
|
||||
@@ -1064,6 +1074,16 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
|
||||
}
|
||||
|
||||
// Desktop layout
|
||||
const settingsMain = (
|
||||
<AppMain ref={attachContentScroll as (el: HTMLElement | null) => void} embedded={isEmbedded} label={activeTabLabel} className="flex-1 overflow-y-auto">
|
||||
<div className={effectiveActiveTab.startsWith(PLUGIN_PAGE_PREFIX) ? "px-6 py-6" : "max-w-3xl mx-auto px-6 py-6"}>
|
||||
{/* The page's heading for screen readers; the sidebar shows which
|
||||
page is open (the phone header has a visible one). */}
|
||||
<h1 className="sr-only">{activeTabLabel}</h1>
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
</AppMain>
|
||||
);
|
||||
return (
|
||||
<div className={cn("flex flex-col pt-[env(safe-area-inset-top)]", mvShell ? "bg-[var(--mv-canvas)]" : "bg-background", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
{!isEmbedded && <SkipLink>{tShell("skip_to_content")}</SkipLink>}
|
||||
@@ -1211,14 +1231,23 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
|
||||
onDoubleClick={() => { setSettingsSidebarWidth(256); localStorage.setItem('settings-sidebar-width', '256'); }}
|
||||
/>
|
||||
|
||||
<AppMain ref={attachContentScroll as (el: HTMLElement | null) => void} embedded={isEmbedded} label={activeTabLabel} className="flex-1 overflow-y-auto">
|
||||
<div className={effectiveActiveTab.startsWith(PLUGIN_PAGE_PREFIX) ? "px-6 py-6" : "max-w-3xl mx-auto px-6 py-6"}>
|
||||
{/* The page's heading for screen readers; the sidebar shows which
|
||||
page is open (the phone header has a visible one). */}
|
||||
<h1 className="sr-only">{activeTabLabel}</h1>
|
||||
{renderTabContent()}
|
||||
{/* The page under a row with its name and the plugin side panel's
|
||||
button, like the reader's toolbar, lined up with the sidebar's h-14
|
||||
row. The same element with or without the panel, so a page keeps
|
||||
what was typed when the panel starts or stops docking. */}
|
||||
<div className="flex-1 min-w-0 flex flex-col" data-settings-side-panel-column="">
|
||||
{sidePanel.docks && (
|
||||
<div className="flex items-center gap-2 h-14 box-content border-b border-border px-2 sm:px-4 lg:px-6 shrink-0" data-settings-panel-row="">
|
||||
{/* The page's h1 below names it for screen readers. */}
|
||||
<div className="flex-1 min-w-0 truncate text-sm font-semibold" aria-hidden="true">{activeTabLabel}</div>
|
||||
{sidePanel.toggle}
|
||||
</div>
|
||||
)}
|
||||
<div ref={sidePanel.rowRef} className="relative flex flex-1 min-h-0">
|
||||
{settingsMain}
|
||||
{sidePanel.panel}
|
||||
</div>
|
||||
</AppMain>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
|
||||
Reference in New Issue
Block a user