mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
fix: keep unsaved admin edits when a window or tablet crosses the desktop breakpoint
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('next/navigation', () => ({ usePathname: () => '/admin/settings' }));
|
||||
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
|
||||
vi.mock('@/components/settings/about-data-settings', () => ({ AboutFooter: () => null }));
|
||||
vi.mock('@/lib/browser-navigation', () => ({
|
||||
apiFetch: vi.fn(() => Promise.resolve({ ok: false })),
|
||||
getPathPrefix: () => '',
|
||||
withBasePath: (href: string) => href,
|
||||
}));
|
||||
|
||||
import { AdminShell } from '../admin-shell';
|
||||
|
||||
// Turning a tablet round crosses the breakpoint between the shell's two
|
||||
// layouts. The page used to be remounted there, so what the admin had typed
|
||||
// and not yet saved was gone without a word.
|
||||
describe('AdminShell across the desktop breakpoint', () => {
|
||||
function mockWidth(initial: boolean) {
|
||||
let desktop = initial;
|
||||
const listeners = new Set<() => void>();
|
||||
window.matchMedia = vi.fn((query: string) => ({
|
||||
get matches() { return desktop && query === '(width >= 64rem)'; },
|
||||
media: query,
|
||||
addEventListener: (_: string, cb: () => void) => listeners.add(cb),
|
||||
removeEventListener: (_: string, cb: () => void) => listeners.delete(cb),
|
||||
})) as unknown as typeof window.matchMedia;
|
||||
return (next: boolean) => act(() => {
|
||||
desktop = next;
|
||||
listeners.forEach((cb) => cb());
|
||||
});
|
||||
}
|
||||
|
||||
function Page({ onMount }: { onMount: () => void }) {
|
||||
const [value, setValue] = useState('saved');
|
||||
useEffect(onMount, [onMount]);
|
||||
return <input aria-label="field" value={value} onChange={(e) => setValue(e.target.value)} />;
|
||||
}
|
||||
|
||||
it('keeps the page mounted, with its edits, in both directions', () => {
|
||||
Element.prototype.scrollTo = () => {};
|
||||
const setDesktop = mockWidth(true);
|
||||
const onMount = vi.fn();
|
||||
render(
|
||||
<AdminShell authenticated isStalwartAutoLogin={false} configReadOnly={false} onLogout={() => {}}>
|
||||
<Page onMount={onMount} />
|
||||
</AdminShell>,
|
||||
);
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'field' }), { target: { value: 'edited' } });
|
||||
|
||||
setDesktop(false);
|
||||
expect(screen.getByRole('textbox', { name: 'field' })).toHaveValue('edited');
|
||||
setDesktop(true);
|
||||
expect(screen.getByRole('textbox', { name: 'field' })).toHaveValue('edited');
|
||||
expect(onMount).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { ArrowLeft, ChevronRight, KeyRound, LogOut, Search, Shield, X } from '@/components/icons';
|
||||
@@ -62,6 +63,15 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly,
|
||||
const [pendingApprovals, setPendingApprovals] = useState(0);
|
||||
const [pendingSection, setPendingSection] = useState<{ page: AdminPageId; id: string } | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
// The two layouts are different trees, so a page drawn inside them would be
|
||||
// remounted when the window crosses the breakpoint (a tablet turned round)
|
||||
// and lose what the admin had typed. It lives in one element instead, which
|
||||
// moves into whichever layout is showing. The portal comes after the layout
|
||||
// so the element is in the document before the page's effects run.
|
||||
const [pageHost] = useState(() => (typeof document === 'undefined' ? null : document.createElement('div')));
|
||||
const pageSlot = useCallback((slot: HTMLDivElement | null) => {
|
||||
if (slot && pageHost) slot.appendChild(pageHost);
|
||||
}, [pageHost]);
|
||||
|
||||
const onChangePassword = pathname === '/admin/change-password';
|
||||
const activePage: AdminPageId | null = location.page ?? (isDesktop && !onChangePassword ? 'dashboard' : null);
|
||||
@@ -257,8 +267,10 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly,
|
||||
);
|
||||
}
|
||||
|
||||
const showIndex = location.page === null && !onChangePassword;
|
||||
const page = pageHost && (isDesktop || !showIndex) ? createPortal(children, pageHost) : null;
|
||||
|
||||
if (!isDesktop) {
|
||||
const showIndex = location.page === null && !onChangePassword;
|
||||
const title = onChangePassword ? 'Change password' : activeDef?.label ?? 'Admin';
|
||||
const backHref = location.detail ? adminPath(location.page) : '/admin';
|
||||
return (
|
||||
@@ -346,7 +358,7 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly,
|
||||
) : (
|
||||
<div className="p-4 pb-6">
|
||||
{readOnlyBanner}
|
||||
{children}
|
||||
<div ref={pageSlot} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -355,6 +367,7 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly,
|
||||
|
||||
<NavigationRail orientation="horizontal" adminActive />
|
||||
</div>
|
||||
{page}
|
||||
</AdminShellContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -445,12 +458,13 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly,
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
||||
<div className="mx-auto max-w-3xl px-6 py-6">
|
||||
{readOnlyBanner}
|
||||
{children}
|
||||
<div ref={pageSlot} />
|
||||
</div>
|
||||
</div>
|
||||
<div ref={setSaveBarHost} className="shrink-0" />
|
||||
</main>
|
||||
</div>
|
||||
{page}
|
||||
</AdminShellContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user