From accfa5373e4c746652c7aedb4028de864fba24c4 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 15 Apr 2026 13:31:17 +0200 Subject: [PATCH] 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 --- .../components/UnsavedWarningBanner.svelte | 22 +++++ .../useUnsavedWarning.svelte.test.ts | 95 +++++++++++++++++++ .../src/lib/hooks/useUnsavedWarning.svelte.ts | 46 +++++++++ .../src/routes/admin/groups/[id]/+page.svelte | 44 ++------- .../src/routes/admin/tags/[id]/+page.svelte | 44 ++------- .../src/routes/admin/users/[id]/+page.svelte | 44 ++------- 6 files changed, 184 insertions(+), 111 deletions(-) create mode 100644 frontend/src/lib/components/UnsavedWarningBanner.svelte create mode 100644 frontend/src/lib/hooks/__tests__/useUnsavedWarning.svelte.test.ts create mode 100644 frontend/src/lib/hooks/useUnsavedWarning.svelte.ts diff --git a/frontend/src/lib/components/UnsavedWarningBanner.svelte b/frontend/src/lib/components/UnsavedWarningBanner.svelte new file mode 100644 index 00000000..76d07b58 --- /dev/null +++ b/frontend/src/lib/components/UnsavedWarningBanner.svelte @@ -0,0 +1,22 @@ + + +
+ {m.admin_unsaved_warning()} + +
diff --git a/frontend/src/lib/hooks/__tests__/useUnsavedWarning.svelte.test.ts b/frontend/src/lib/hooks/__tests__/useUnsavedWarning.svelte.test.ts new file mode 100644 index 00000000..9225a956 --- /dev/null +++ b/frontend/src/lib/hooks/__tests__/useUnsavedWarning.svelte.test.ts @@ -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); + }); +}); diff --git a/frontend/src/lib/hooks/useUnsavedWarning.svelte.ts b/frontend/src/lib/hooks/useUnsavedWarning.svelte.ts new file mode 100644 index 00000000..b43bf7e5 --- /dev/null +++ b/frontend/src/lib/hooks/useUnsavedWarning.svelte.ts @@ -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 + }; +} diff --git a/frontend/src/routes/admin/groups/[id]/+page.svelte b/frontend/src/routes/admin/groups/[id]/+page.svelte index 898a8214..d17d102f 100644 --- a/frontend/src/routes/admin/groups/[id]/+page.svelte +++ b/frontend/src/routes/admin/groups/[id]/+page.svelte @@ -1,16 +1,15 @@ @@ -53,23 +41,8 @@ $effect(() => {
- {#if showUnsavedWarning} -
- {m.admin_unsaved_warning()} - -
+ {#if unsaved.showUnsavedWarning} + {/if} {#if form?.success}
@@ -88,10 +61,7 @@ $effect(() => { method="POST" action="?/update" use:enhance - oninput={() => { - isDirty = true; - showUnsavedWarning = false; - }} + oninput={unsaved.markDirty} class="mb-5" >
diff --git a/frontend/src/routes/admin/users/[id]/+page.svelte b/frontend/src/routes/admin/users/[id]/+page.svelte index c0e8f8db..161e63ad 100644 --- a/frontend/src/routes/admin/users/[id]/+page.svelte +++ b/frontend/src/routes/admin/users/[id]/+page.svelte @@ -1,21 +1,20 @@ @@ -76,23 +64,8 @@ $effect(() => {
- {#if showUnsavedWarning} -
- {m.admin_unsaved_warning()} - -
+ {#if unsaved.showUnsavedWarning} + {/if} {#if form?.success}
@@ -109,10 +82,7 @@ $effect(() => { id="edit-user-form" method="POST" use:enhance - oninput={() => { - isDirty = true; - showUnsavedWarning = false; - }} + oninput={unsaved.markDirty} class="space-y-5" >