fix: keep unsaved admin edits when a window or tablet crosses the desktop breakpoint

This commit is contained in:
Linus Rath
2026-11-03 05:36:14 +01:00
parent 351e41d495
commit cd685d41b3
2 changed files with 75 additions and 3 deletions
@@ -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);
});
});
+17 -3
View File
@@ -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>
);
}