From 1482cb7a06ac5e53739a28f3986c5e8c4c4b6ee1 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 14 May 2026 11:53:15 +0200 Subject: [PATCH] fix(admin): clear unsaved-changes guard before redirect on users/new Mirror the groups/new fix: replace inline beforeNavigate/isDirty with createUnsavedWarning() + UnsavedWarningBanner and add an enhance callback that calls clearOnSuccess() before update() on redirect results. Co-Authored-By: Claude Sonnet 4.6 --- .../src/routes/admin/users/new/+page.svelte | 42 ++---- .../admin/users/new/page.svelte.spec.ts | 120 +++++++++++++++++- 2 files changed, 127 insertions(+), 35 deletions(-) diff --git a/frontend/src/routes/admin/users/new/+page.svelte b/frontend/src/routes/admin/users/new/+page.svelte index 14cb3f2d..27b10e32 100644 --- a/frontend/src/routes/admin/users/new/+page.svelte +++ b/frontend/src/routes/admin/users/new/+page.svelte @@ -1,24 +1,15 @@
@@ -44,23 +35,8 @@ beforeNavigate(({ cancel, to }) => {
- {#if showUnsavedWarning} -
- {m.admin_unsaved_warning()} - -
+ {#if unsaved.showUnsavedWarning} + {/if} {#if form?.error}
@@ -71,11 +47,11 @@ beforeNavigate(({ cancel, to }) => {
{ - isDirty = true; - showUnsavedWarning = false; + use:enhance={() => async ({ result, update }) => { + if (result.type === 'redirect') unsaved.clearOnSuccess(); + await update(); }} + oninput={unsaved.markDirty} class="space-y-5" >
diff --git a/frontend/src/routes/admin/users/new/page.svelte.spec.ts b/frontend/src/routes/admin/users/new/page.svelte.spec.ts index 9cef90e1..4ae16236 100644 --- a/frontend/src/routes/admin/users/new/page.svelte.spec.ts +++ b/frontend/src/routes/admin/users/new/page.svelte.spec.ts @@ -1,9 +1,19 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; import Page from './+page.svelte'; -vi.mock('$app/forms', () => ({ enhance: () => () => {} })); +const enhanceCaptureRef = vi.hoisted(() => ({ submitFn: undefined as unknown })); + +vi.mock('$app/forms', () => ({ + enhance: (_el: HTMLFormElement, fn?: unknown) => { + enhanceCaptureRef.submitFn = fn; + return { destroy: vi.fn() }; + } +})); +vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), goto: vi.fn() })); + +import { beforeNavigate, goto } from '$app/navigation'; const groups = [ { id: 'g1', name: 'Editoren', permissions: ['WRITE_ALL'] }, @@ -66,3 +76,109 @@ describe('Admin new user page – error display', () => { await expect.element(page.getByText('Ein Fehler ist aufgetreten.')).not.toBeInTheDocument(); }); }); + +// ─── Unsaved-changes guard ──────────────────────────────────────────────────── + +describe('Admin new user page – unsaved-changes guard', () => { + beforeEach(() => { + vi.clearAllMocks(); + enhanceCaptureRef.submitFn = undefined; + }); + + it('does not show unsaved warning initially', async () => { + render(Page, { data: baseData, form: null }); + await expect.element(page.getByText(/ungespeicherte Änderungen/i)).not.toBeInTheDocument(); + }); + + it('cancels navigation and shows banner when form is dirty', async () => { + render(Page, { data: baseData, form: null }); + const [callback] = vi.mocked(beforeNavigate).mock.calls[0]; + + document + .querySelector('input[name="email"]')! + .dispatchEvent(new InputEvent('input', { bubbles: true })); + + const cancel = vi.fn(); + callback({ cancel, to: { url: new URL('http://localhost/admin/users') } }); + + expect(cancel).toHaveBeenCalled(); + await expect.element(page.getByText(/ungespeicherte Änderungen/i)).toBeInTheDocument(); + }); + + it('does not cancel navigation when form is clean', async () => { + render(Page, { data: baseData, form: null }); + const [callback] = vi.mocked(beforeNavigate).mock.calls[0]; + + const cancel = vi.fn(); + callback({ cancel, to: { url: new URL('http://localhost/admin/users') } }); + + expect(cancel).not.toHaveBeenCalled(); + }); + + it('discard button calls goto with the target URL', async () => { + render(Page, { data: baseData, form: null }); + const [callback] = vi.mocked(beforeNavigate).mock.calls[0]; + + document + .querySelector('input[name="email"]')! + .dispatchEvent(new InputEvent('input', { bubbles: true })); + + callback({ cancel: vi.fn(), to: { url: new URL('http://localhost/admin/users') } }); + + await page.getByRole('button', { name: /verwerfen/i }).click(); + + expect(vi.mocked(goto)).toHaveBeenCalledWith('http://localhost/admin/users'); + }); + + it('clears banner when enhance callback receives a redirect result', async () => { + render(Page, { data: baseData, form: null }); + const [navCallback] = vi.mocked(beforeNavigate).mock.calls[0]; + + document + .querySelector('input[name="email"]')! + .dispatchEvent(new InputEvent('input', { bubbles: true })); + + navCallback({ cancel: vi.fn(), to: { url: new URL('http://localhost/admin/users') } }); + await expect.element(page.getByText(/ungespeicherte Änderungen/i)).toBeInTheDocument(); + + type SubmitFn = () => Promise< + (opts: { result: { type: string }; update: () => Promise }) => Promise + >; + const innerFn = await (enhanceCaptureRef.submitFn as SubmitFn)(); + await innerFn({ + result: { type: 'redirect', location: '/admin/users', status: 303 }, + update: vi.fn().mockResolvedValue(undefined) + }); + + await expect.element(page.getByText(/ungespeicherte Änderungen/i)).not.toBeInTheDocument(); + + const cancel = vi.fn(); + navCallback({ cancel, to: { url: new URL('http://localhost/admin/users') } }); + expect(cancel).not.toHaveBeenCalled(); + }); + + it('keeps banner when enhance callback receives a failure result', async () => { + render(Page, { data: baseData, form: null }); + const [navCallback] = vi.mocked(beforeNavigate).mock.calls[0]; + + document + .querySelector('input[name="email"]')! + .dispatchEvent(new InputEvent('input', { bubbles: true })); + + navCallback({ cancel: vi.fn(), to: { url: new URL('http://localhost/admin/users') } }); + await expect.element(page.getByText(/ungespeicherte Änderungen/i)).toBeInTheDocument(); + + type SubmitFn = () => Promise< + (opts: { result: { type: string }; update: () => Promise }) => Promise + >; + const innerFn = await (enhanceCaptureRef.submitFn as SubmitFn)(); + await innerFn({ + result: { type: 'failure', status: 400, data: { error: 'E-Mail bereits vergeben' } }, + update: vi.fn().mockResolvedValue(undefined) + }); + + const cancel = vi.fn(); + navCallback({ cancel, to: { url: new URL('http://localhost/admin/users') } }); + expect(cancel).toHaveBeenCalled(); + }); +});