diff --git a/app/(main)/admin/__tests__/unsaved-changes-guard.test.tsx b/app/(main)/admin/__tests__/unsaved-changes-guard.test.tsx new file mode 100644 index 000000000..34fa74eb8 --- /dev/null +++ b/app/(main)/admin/__tests__/unsaved-changes-guard.test.tsx @@ -0,0 +1,94 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { UnsavedChangesGuard } from '../_unsaved-changes-guard'; +import { useAdminUnsavedChanges, useAdminUnsavedStore } from '@/stores/admin-unsaved-store'; + +function DirtySection({ id = 'settings', dirty }: { id?: string; dirty: boolean }) { + useAdminUnsavedChanges(id, dirty); + return null; +} + +afterEach(() => { + useAdminUnsavedStore.setState({ dirty: {}, pendingLeave: null, pendingScope: null }); +}); + +describe('admin unsaved changes guard (#1255)', () => { + it('runs the action straight away when nothing is unsaved', () => { + render(<>); + const action = vi.fn(); + act(() => useAdminUnsavedStore.getState().requestLeave(action)); + expect(action).toHaveBeenCalledTimes(1); + expect(screen.queryByRole('alertdialog')).toBeNull(); + }); + + it('asks before leaving with unsaved edits; Stay keeps the admin on the page', () => { + render(<>); + const action = vi.fn(); + act(() => useAdminUnsavedStore.getState().requestLeave(action)); + expect(screen.getByRole('alertdialog')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: 'Stay' })); + expect(action).not.toHaveBeenCalled(); + expect(screen.queryByRole('alertdialog')).toBeNull(); + expect(useAdminUnsavedStore.getState().dirty).toEqual({ settings: true }); + }); + + it('Leave runs the action and drops the unsaved flag', () => { + render(<>); + const action = vi.fn(); + act(() => useAdminUnsavedStore.getState().requestLeave(action)); + fireEvent.click(screen.getByRole('button', { name: 'Leave' })); + expect(action).toHaveBeenCalledTimes(1); + expect(useAdminUnsavedStore.getState().dirty).toEqual({}); + }); + + it('Escape closes the prompt like Stay', () => { + render(<>); + const action = vi.fn(); + act(() => useAdminUnsavedStore.getState().requestLeave(action)); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(screen.queryByRole('alertdialog')).toBeNull(); + expect(action).not.toHaveBeenCalled(); + }); + + it('a scoped leave only looks at, and only clears, its own section', () => { + render(<>); + const back = vi.fn(); + act(() => useAdminUnsavedStore.getState().requestLeave(back, 'plugin-config')); + expect(back).toHaveBeenCalledTimes(1); + + act(() => useAdminUnsavedStore.getState().setDirty('plugin-config', true)); + act(() => useAdminUnsavedStore.getState().requestLeave(back, 'plugin-config')); + fireEvent.click(screen.getByRole('button', { name: 'Leave' })); + expect(back).toHaveBeenCalledTimes(2); + expect(useAdminUnsavedStore.getState().dirty).toEqual({ plugins: true }); + }); + + it('holds a link click until the admin picks Leave', () => { + const onLinkClick = vi.fn((e: React.MouseEvent) => e.preventDefault()); + render( + <> + + + Change Password + , + ); + fireEvent.click(screen.getByText('Change Password')); + expect(onLinkClick).not.toHaveBeenCalled(); + expect(screen.getByRole('alertdialog')).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: 'Leave' })); + expect(onLinkClick).toHaveBeenCalledTimes(1); + }); + + it('asks the browser to confirm closing the page only while edits are unsaved', () => { + const { rerender } = render(<>); + const dirtyEvent = new Event('beforeunload', { cancelable: true }); + window.dispatchEvent(dirtyEvent); + expect(dirtyEvent.defaultPrevented).toBe(true); + + rerender(<>); + const cleanEvent = new Event('beforeunload', { cancelable: true }); + window.dispatchEvent(cleanEvent); + expect(cleanEvent.defaultPrevented).toBe(false); + }); +}); diff --git a/app/(main)/admin/_tabs/auth.tsx b/app/(main)/admin/_tabs/auth.tsx index 1ee2a3c2a..7d28e969b 100644 --- a/app/(main)/admin/_tabs/auth.tsx +++ b/app/(main)/admin/_tabs/auth.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'; import { Save, Loader2, RotateCcw, Sparkles } from '@/components/icons'; import { apiFetch } from '@/lib/browser-navigation'; +import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store'; import { DEFAULT_OAUTH_SCOPES } from '@/lib/oauth/scopes'; interface ConfigEntry { @@ -135,6 +136,8 @@ export function AuthTab() { const hasEdits = Object.keys(edits).length > 0; + useAdminUnsavedChanges('auth', hasEdits); + if (loading) { return
Loading...
; } diff --git a/app/(main)/admin/_tabs/branding.tsx b/app/(main)/admin/_tabs/branding.tsx index c93da7367..b899a8256 100644 --- a/app/(main)/admin/_tabs/branding.tsx +++ b/app/(main)/admin/_tabs/branding.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2, Globe, Plus, X } from '@/components/icons'; import { apiFetch, withBasePath } from '@/lib/browser-navigation'; +import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store'; import { BRANDING_OVERRIDE_KEYS, parseDomainBranding, @@ -340,6 +341,8 @@ export function BrandingTab() { const hasEdits = Object.keys(edits).length > 0; const wildcardScope = !!selectedHost && !EXACT_HOST_RE.test(selectedHost); + useAdminUnsavedChanges('branding', hasEdits); + if (loading) { return
Loading...
; } diff --git a/app/(main)/admin/_tabs/plugin-config-panel.tsx b/app/(main)/admin/_tabs/plugin-config-panel.tsx index d396c086a..9f5370614 100644 --- a/app/(main)/admin/_tabs/plugin-config-panel.tsx +++ b/app/(main)/admin/_tabs/plugin-config-panel.tsx @@ -1,8 +1,9 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Puzzle, ArrowLeft, Loader2, Eye, EyeOff } from '@/components/icons'; import { apiFetch } from '@/lib/browser-navigation'; +import { useAdminUnsavedChanges, useAdminUnsavedStore } from '@/stores/admin-unsaved-store'; import { usePluginSlotOffers } from '@/hooks/use-plugin-slot-offers'; import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot'; @@ -73,9 +74,9 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) { return () => { cancelled = true; }; }, [pluginId]); - useEffect(() => { - if (!plugin?.configSchema) return; + const savedValues = useMemo(() => { const initial: Record = {}; + if (!plugin?.configSchema) return initial; for (const [key, field] of Object.entries(plugin.configSchema)) { const stored = config[key]; if (stored !== undefined && stored !== null) { @@ -86,9 +87,19 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) { initial[key] = ''; } } - setFormValues(initial); + return initial; }, [plugin, config]); + useEffect(() => { + if (!plugin?.configSchema) return; + setFormValues(savedValues); + }, [plugin, savedValues]); + + const dirty = Object.keys(savedValues).some(key => (formValues[key] ?? '') !== savedValues[key]); + useAdminUnsavedChanges('plugin-config', dirty); + const requestLeave = useAdminUnsavedStore((s) => s.requestLeave); + const handleBack = () => requestLeave(onBack, 'plugin-config'); + async function handleSaveAll() { if (!plugin?.configSchema) return; setSaving(true); @@ -166,7 +177,7 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
+ +
+ + + ); +} diff --git a/app/(main)/admin/layout.tsx b/app/(main)/admin/layout.tsx index 9507b774f..201cc68d7 100644 --- a/app/(main)/admin/layout.tsx +++ b/app/(main)/admin/layout.tsx @@ -33,6 +33,8 @@ import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot'; import { useUpdateStore, selectHasUpdate } from '@/stores/update-store'; import { apiFetch, getPathPrefix, withBasePath } from '@/lib/browser-navigation'; +import { useAdminUnsavedStore } from '@/stores/admin-unsaved-store'; +import { UnsavedChangesGuard } from './_unsaved-changes-guard'; // Single-page tab navigation: clicks update a Zustand store. The URL stays // at /admin so React doesn't fire a route transition on every tab switch - @@ -80,6 +82,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) const pathname = usePathname(); const storeActiveTab = useAdminTabStore((s) => s.activeTab); const setActiveTab = useAdminTabStore((s) => s.setActiveTab); + const requestLeave = useAdminUnsavedStore((s) => s.requestLeave); // Highlight the active tab only on /admin itself - on dynamic routes // (e.g. /admin/plugins/[id]) no tab is "current". const activeTab = pathname === '/admin' ? storeActiveTab : null; @@ -175,9 +178,11 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) return () => { cancelled = true; }; }, [pathname, router]); - async function handleLogout() { - await apiFetch('/api/admin/auth', { method: 'DELETE' }); - router.replace('/admin/login'); + function handleLogout() { + requestLeave(async () => { + await apiFetch('/api/admin/auth', { method: 'DELETE' }); + router.replace('/admin/login'); + }); } // Don't gate the login page @@ -207,10 +212,13 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) const active = activeTab === tab; const showDot = tab === 'version' && hasUpdate; const handleClick = () => { - setActiveTab(tab); - // From a dynamic route (/admin/plugins/[id], /admin/marketplace/[slug]) - // we still need a real navigation back to /admin so the page renders. - if (pathname !== '/admin') router.push('/admin'); + if (active) return; + requestLeave(() => { + setActiveTab(tab); + // From a dynamic route (/admin/plugins/[id], /admin/marketplace/[slug]) + // we still need a real navigation back to /admin so the page renders. + if (pathname !== '/admin') router.push('/admin'); + }); }; return (