refactor(unsaved): extract createUnsavedWarning hook and UnsavedWarningBanner

Move the identical isDirty / beforeNavigate / discard pattern out of the
three admin detail pages (groups, tags, users) into a reusable
createUnsavedWarning() hook and a UnsavedWarningBanner presentational
component.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-15 13:31:17 +02:00
parent 34e7436fdc
commit accfa5373e
6 changed files with 184 additions and 111 deletions

View File

@@ -0,0 +1,95 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Capture the beforeNavigate callback so tests can simulate navigation events
let registeredBeforeNavigate:
| ((nav: { cancel: () => void; to: { url: { href: string } } | null }) => void)
| null = null;
const mockGoto = vi.fn();
vi.mock('$app/navigation', () => ({
beforeNavigate: vi.fn((fn: typeof registeredBeforeNavigate) => {
registeredBeforeNavigate = fn;
}),
goto: mockGoto
}));
const { createUnsavedWarning } = await import('../useUnsavedWarning.svelte');
function simulateNavigate(href: string | null = '/somewhere') {
const cancel = vi.fn();
registeredBeforeNavigate?.({
cancel,
to: href ? { url: { href } } : null
});
return cancel;
}
beforeEach(() => {
registeredBeforeNavigate = null;
mockGoto.mockClear();
});
describe('createUnsavedWarning', () => {
it('isDirty starts false', () => {
const w = createUnsavedWarning();
expect(w.isDirty).toBe(false);
});
it('markDirty sets isDirty to true', () => {
const w = createUnsavedWarning();
w.markDirty();
expect(w.isDirty).toBe(true);
});
it('markDirty hides any existing warning banner', () => {
const w = createUnsavedWarning();
// Simulate a navigation event that showed the banner
w.markDirty();
simulateNavigate();
expect(w.showUnsavedWarning).toBe(true);
// Typing again should hide the banner (form input re-triggers markDirty)
w.markDirty();
expect(w.showUnsavedWarning).toBe(false);
});
it('beforeNavigate cancels and shows banner when dirty', () => {
const w = createUnsavedWarning();
w.markDirty();
const cancel = simulateNavigate('/admin/users');
expect(cancel).toHaveBeenCalled();
expect(w.showUnsavedWarning).toBe(true);
});
it('beforeNavigate stores the target URL', () => {
const w = createUnsavedWarning();
w.markDirty();
simulateNavigate('/admin/users');
expect(w.discardTarget).toBe('/admin/users');
});
it('beforeNavigate does not cancel when not dirty', () => {
createUnsavedWarning();
const cancel = simulateNavigate('/admin/users');
expect(cancel).not.toHaveBeenCalled();
});
it('discard resets state and navigates to target', () => {
const w = createUnsavedWarning();
w.markDirty();
simulateNavigate('/admin/tags');
w.discard();
expect(w.isDirty).toBe(false);
expect(w.showUnsavedWarning).toBe(false);
expect(mockGoto).toHaveBeenCalledWith('/admin/tags');
});
it('clearOnSuccess resets isDirty and warning', () => {
const w = createUnsavedWarning();
w.markDirty();
simulateNavigate('/somewhere');
w.clearOnSuccess();
expect(w.isDirty).toBe(false);
expect(w.showUnsavedWarning).toBe(false);
});
});

View File

@@ -0,0 +1,46 @@
import { beforeNavigate, goto } from '$app/navigation';
export function createUnsavedWarning() {
let isDirty = $state(false);
let showUnsavedWarning = $state(false);
let discardTarget: string | null = $state(null);
beforeNavigate(({ cancel, to }) => {
if (isDirty) {
cancel();
showUnsavedWarning = true;
discardTarget = to?.url.href ?? null;
}
});
function markDirty() {
isDirty = true;
showUnsavedWarning = false;
}
function discard() {
isDirty = false;
showUnsavedWarning = false;
if (discardTarget) goto(discardTarget);
}
function clearOnSuccess() {
isDirty = false;
showUnsavedWarning = false;
}
return {
get isDirty() {
return isDirty;
},
get showUnsavedWarning() {
return showUnsavedWarning;
},
get discardTarget() {
return discardTarget;
},
markDirty,
discard,
clearOnSuccess
};
}