mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: ask before leaving the admin panel with unsaved changes #1255
This commit is contained in:
@@ -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(<><UnsavedChangesGuard /><DirtySection dirty={false} /></>);
|
||||
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(<><UnsavedChangesGuard /><DirtySection dirty /></>);
|
||||
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(<><UnsavedChangesGuard /><DirtySection dirty /></>);
|
||||
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(<><UnsavedChangesGuard /><DirtySection dirty /></>);
|
||||
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(<><UnsavedChangesGuard /><DirtySection id="plugins" dirty /><DirtySection id="plugin-config" dirty={false} /></>);
|
||||
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(
|
||||
<>
|
||||
<UnsavedChangesGuard />
|
||||
<DirtySection dirty />
|
||||
<a href="/admin/change-password" onClick={onLinkClick}>Change Password</a>
|
||||
</>,
|
||||
);
|
||||
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(<><UnsavedChangesGuard /><DirtySection dirty /></>);
|
||||
const dirtyEvent = new Event('beforeunload', { cancelable: true });
|
||||
window.dispatchEvent(dirtyEvent);
|
||||
expect(dirtyEvent.defaultPrevented).toBe(true);
|
||||
|
||||
rerender(<><UnsavedChangesGuard /><DirtySection dirty={false} /></>);
|
||||
const cleanEvent = new Event('beforeunload', { cancelable: true });
|
||||
window.dispatchEvent(cleanEvent);
|
||||
expect(cleanEvent.defaultPrevented).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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 <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||
}
|
||||
|
||||
@@ -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 <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||
}
|
||||
|
||||
@@ -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<string, string> = {};
|
||||
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) {
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
onClick={handleBack}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" /> Back to Plugins
|
||||
@@ -184,7 +195,7 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
onClick={handleBack}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||
aria-label="Back to Plugins"
|
||||
>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Upload, Trash2, Power, PowerOff, AlertTriangle, Loader2, Package, Save,
|
||||
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||
import { DEFAULT_POLICY } from '@/lib/admin/types';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store';
|
||||
import { PluginConfigPanel } from './plugin-config-panel';
|
||||
import { PluginApprovalsPanel } from './plugin-approvals-panel';
|
||||
|
||||
@@ -252,6 +253,8 @@ export function PluginsTab() {
|
||||
}
|
||||
}
|
||||
|
||||
useAdminUnsavedChanges('plugins', policyDirty);
|
||||
|
||||
if (configuringId) {
|
||||
return <PluginConfigPanel pluginId={configuringId} onBack={() => { setConfiguringId(null); fetchPlugins(); }} />;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { iconForName } from '@/components/icons';
|
||||
import type { SettingsPolicy, FeatureGates, PushRelayOption, AdminSidebarApp } from '@/lib/admin/types';
|
||||
import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store';
|
||||
import { DEFAULT_SIDEBAR_APP_ID_PREFIX, MAX_DEFAULT_SIDEBAR_APPS } from '@/lib/sidebar-apps';
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
import {
|
||||
@@ -249,6 +250,8 @@ export function PolicyTab() {
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
useAdminUnsavedChanges('policy', dirty);
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Save, RotateCcw, Loader2 } from '@/components/icons';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store';
|
||||
import { JmapServersSection } from './_jmap-servers-section';
|
||||
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
|
||||
|
||||
@@ -83,6 +84,8 @@ export function SettingsTab() {
|
||||
|
||||
const hasEdits = Object.keys(edits).length > 0;
|
||||
|
||||
useAdminUnsavedChanges('settings', hasEdits);
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Upload, Trash2, Power, PowerOff, Loader2, Palette, Save, Shield, Lock,
|
||||
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||
import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store';
|
||||
import { BUILTIN_THEMES } from '@/lib/builtin-themes';
|
||||
|
||||
// Derive from the single source of truth so newly added built-in themes show
|
||||
@@ -297,6 +298,8 @@ export function ThemesTab() {
|
||||
}
|
||||
}
|
||||
|
||||
useAdminUnsavedChanges('themes', policyDirty);
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useId } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AlertTriangle } from '@/components/icons';
|
||||
import { useFocusTrap } from '@/hooks/use-focus-trap';
|
||||
import { useAdminUnsavedStore, selectHasUnsaved } from '@/stores/admin-unsaved-store';
|
||||
|
||||
/**
|
||||
* Asks before the admin leaves a section with unsaved edits (#1255). Tab
|
||||
* buttons and sign out go through requestLeave themselves; links anywhere on
|
||||
* the page are caught here, and closing or reloading the tab gets the
|
||||
* browser's own prompt.
|
||||
*/
|
||||
export function UnsavedChangesGuard() {
|
||||
const hasUnsaved = useAdminUnsavedStore(selectHasUnsaved);
|
||||
const pendingLeave = useAdminUnsavedStore((s) => s.pendingLeave);
|
||||
const requestLeave = useAdminUnsavedStore((s) => s.requestLeave);
|
||||
const confirmLeave = useAdminUnsavedStore((s) => s.confirmLeave);
|
||||
const cancelLeave = useAdminUnsavedStore((s) => s.cancelLeave);
|
||||
const isOpen = pendingLeave !== null;
|
||||
const id = useId();
|
||||
|
||||
const dialogRef = useFocusTrap({ isActive: isOpen, restoreFocus: true });
|
||||
|
||||
// On document, not the dialog, so Escape means "Stay" wherever focus is.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
cancelLeave();
|
||||
};
|
||||
document.addEventListener('keydown', onKeyDown, true);
|
||||
return () => document.removeEventListener('keydown', onKeyDown, true);
|
||||
}, [isOpen, cancelLeave]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasUnsaved) return;
|
||||
const onBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
};
|
||||
window.addEventListener('beforeunload', onBeforeUnload);
|
||||
return () => window.removeEventListener('beforeunload', onBeforeUnload);
|
||||
}, [hasUnsaved]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasUnsaved) return;
|
||||
// Capture phase on document runs before React's root listener, so a
|
||||
// Next <Link> never starts its client-side navigation.
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (e.defaultPrevented || e.button !== 0) return;
|
||||
// Read the store, not the render: after "Leave" the click is replayed
|
||||
// before React has removed this listener.
|
||||
if (!selectHasUnsaved(useAdminUnsavedStore.getState())) return;
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||
const anchor = (e.target as Element | null)?.closest?.('a[href]');
|
||||
if (!(anchor instanceof HTMLAnchorElement)) return;
|
||||
if (anchor.target && anchor.target !== '_self') return;
|
||||
if (anchor.hasAttribute('download')) return;
|
||||
const url = new URL(anchor.href, window.location.href);
|
||||
if (url.origin === window.location.origin
|
||||
&& url.pathname === window.location.pathname
|
||||
&& url.search === window.location.search) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
requestLeave(() => {
|
||||
// Nothing is dirty any more, so the second click goes through.
|
||||
if (anchor.isConnected) anchor.click();
|
||||
else window.location.assign(anchor.href);
|
||||
});
|
||||
};
|
||||
document.addEventListener('click', onClick, true);
|
||||
return () => document.removeEventListener('click', onClick, true);
|
||||
}, [hasUnsaved, requestLeave]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150"
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) cancelLeave(); }}
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={`${id}-title`}
|
||||
aria-describedby={`${id}-message`}
|
||||
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-md animate-in zoom-in-95 duration-200"
|
||||
>
|
||||
<div className="p-6 flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-destructive/10 flex items-center justify-center">
|
||||
<AlertTriangle className="w-5 h-5 text-destructive" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 id={`${id}-title`} className="text-lg font-semibold text-foreground">
|
||||
Unsaved changes
|
||||
</h2>
|
||||
<p id={`${id}-message`} className="mt-2 text-sm text-muted-foreground">
|
||||
You have changes that haven't been saved. If you leave now, they will be lost.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 px-6 pb-6">
|
||||
<Button variant="outline" onClick={cancelLeave}>Stay</Button>
|
||||
<Button variant="destructive" onClick={confirmLeave} className="shadow-sm">Leave</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<button
|
||||
@@ -280,6 +288,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex bg-background">
|
||||
<UnsavedChangesGuard />
|
||||
{/* Slim webmail nav rail (desktop only) */}
|
||||
<nav className="hidden md:flex w-14 bg-secondary flex-col items-center py-3 gap-2 border-e border-border sticky top-0 h-screen shrink-0">
|
||||
{logoUrl ? (
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useEffect } from 'react';
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface AdminUnsavedState {
|
||||
/** Sections of the admin panel that hold edits nobody has saved yet. */
|
||||
dirty: Record<string, true>;
|
||||
/** What to run once the admin chooses "Leave" in the prompt. */
|
||||
pendingLeave: (() => void) | null;
|
||||
/** The one section the pending leave discards, or null for all of them. */
|
||||
pendingScope: string | null;
|
||||
setDirty: (id: string, dirty: boolean) => void;
|
||||
/**
|
||||
* Runs `action` now, or asks first when something is unsaved (#1255).
|
||||
* With `scope`, only that section's edits count, for leaving a nested
|
||||
* panel while the section around it stays mounted.
|
||||
*/
|
||||
requestLeave: (action: () => void, scope?: string) => void;
|
||||
confirmLeave: () => void;
|
||||
cancelLeave: () => void;
|
||||
}
|
||||
|
||||
export const useAdminUnsavedStore = create<AdminUnsavedState>()((set, get) => ({
|
||||
dirty: {},
|
||||
pendingLeave: null,
|
||||
pendingScope: null,
|
||||
setDirty: (id, dirty) => {
|
||||
const current = get().dirty;
|
||||
if (dirty === (id in current)) return;
|
||||
const next = { ...current };
|
||||
if (dirty) next[id] = true;
|
||||
else delete next[id];
|
||||
set({ dirty: next });
|
||||
},
|
||||
requestLeave: (action, scope) => {
|
||||
const dirty = get().dirty;
|
||||
if (scope ? !(scope in dirty) : Object.keys(dirty).length === 0) {
|
||||
action();
|
||||
return;
|
||||
}
|
||||
set({ pendingLeave: action, pendingScope: scope ?? null });
|
||||
},
|
||||
confirmLeave: () => {
|
||||
const { pendingLeave: action, pendingScope: scope, dirty } = get();
|
||||
// Clear first: the action unmounts the dirty section, and a hard
|
||||
// navigation must not trip the beforeunload prompt a second time.
|
||||
let next: Record<string, true> = {};
|
||||
if (scope) {
|
||||
next = { ...dirty };
|
||||
delete next[scope];
|
||||
}
|
||||
set({ pendingLeave: null, pendingScope: null, dirty: next });
|
||||
action?.();
|
||||
},
|
||||
cancelLeave: () => set({ pendingLeave: null, pendingScope: null }),
|
||||
}));
|
||||
|
||||
export const selectHasUnsaved = (s: AdminUnsavedState) => Object.keys(s.dirty).length > 0;
|
||||
|
||||
/** Reports a section's unsaved edits to the admin layout's leave prompt. */
|
||||
export function useAdminUnsavedChanges(id: string, dirty: boolean) {
|
||||
const setDirty = useAdminUnsavedStore((s) => s.setDirty);
|
||||
useEffect(() => {
|
||||
setDirty(id, dirty);
|
||||
}, [id, dirty, setDirty]);
|
||||
useEffect(() => () => setDirty(id, false), [id, setDirty]);
|
||||
}
|
||||
Reference in New Issue
Block a user