From 30469e74c94fe133514ee362f0fbe514c557d0d2 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 14 May 2026 11:51:16 +0200 Subject: [PATCH 1/4] fix(admin): clear unsaved-changes guard before redirect on groups/new Use createUnsavedWarning() + UnsavedWarningBanner to replace the inline beforeNavigate/isDirty pattern, and add an enhance callback that calls clearOnSuccess() before update() so the guard is disarmed before SvelteKit's internal goto() fires on a redirect result. Co-Authored-By: Claude Sonnet 4.6 --- .../src/routes/admin/groups/new/+page.svelte | 42 ++---- .../admin/groups/new/page.svelte.spec.ts | 124 ++++++++++++++++++ 2 files changed, 133 insertions(+), 33 deletions(-) create mode 100644 frontend/src/routes/admin/groups/new/page.svelte.spec.ts diff --git a/frontend/src/routes/admin/groups/new/+page.svelte b/frontend/src/routes/admin/groups/new/+page.svelte index 78d7218e..16c1059b 100644 --- a/frontend/src/routes/admin/groups/new/+page.svelte +++ b/frontend/src/routes/admin/groups/new/+page.svelte @@ -1,7 +1,8 @@
@@ -58,23 +49,8 @@ beforeNavigate(({ cancel, to }) => {
- {#if showUnsavedWarning} -
- {m.admin_unsaved_warning()} - -
+ {#if unsaved.showUnsavedWarning} + {/if} {#if form?.error}
@@ -85,11 +61,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/groups/new/page.svelte.spec.ts b/frontend/src/routes/admin/groups/new/page.svelte.spec.ts new file mode 100644 index 00000000..615f4587 --- /dev/null +++ b/frontend/src/routes/admin/groups/new/page.svelte.spec.ts @@ -0,0 +1,124 @@ +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'; + +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'; + +afterEach(cleanup); + +// ─── Unsaved-changes guard ──────────────────────────────────────────────────── + +describe('Admin new group page – unsaved-changes guard', () => { + beforeEach(() => { + vi.clearAllMocks(); + enhanceCaptureRef.submitFn = undefined; + }); + + it('does not show unsaved warning initially', async () => { + render(Page, { props: { 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, { props: { form: null } }); + const [callback] = vi.mocked(beforeNavigate).mock.calls[0]; + + document + .querySelector('input[name="name"]')! + .dispatchEvent(new InputEvent('input', { bubbles: true })); + + const cancel = vi.fn(); + callback({ cancel, to: { url: new URL('http://localhost/admin/groups') } }); + + expect(cancel).toHaveBeenCalled(); + await expect.element(page.getByText(/ungespeicherte Änderungen/i)).toBeInTheDocument(); + }); + + it('does not cancel navigation when form is clean', async () => { + render(Page, { props: { form: null } }); + const [callback] = vi.mocked(beforeNavigate).mock.calls[0]; + + const cancel = vi.fn(); + callback({ cancel, to: { url: new URL('http://localhost/admin/groups') } }); + + expect(cancel).not.toHaveBeenCalled(); + }); + + it('discard button calls goto with the target URL', async () => { + render(Page, { props: { form: null } }); + const [callback] = vi.mocked(beforeNavigate).mock.calls[0]; + + document + .querySelector('input[name="name"]')! + .dispatchEvent(new InputEvent('input', { bubbles: true })); + + callback({ cancel: vi.fn(), to: { url: new URL('http://localhost/admin/groups') } }); + + await page.getByRole('button', { name: /verwerfen/i }).click(); + + expect(vi.mocked(goto)).toHaveBeenCalledWith('http://localhost/admin/groups'); + }); + + it('clears banner when enhance callback receives a redirect result', async () => { + render(Page, { props: { form: null } }); + const [navCallback] = vi.mocked(beforeNavigate).mock.calls[0]; + + document + .querySelector('input[name="name"]')! + .dispatchEvent(new InputEvent('input', { bubbles: true })); + + navCallback({ cancel: vi.fn(), to: { url: new URL('http://localhost/admin/groups') } }); + 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/groups', 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/groups') } }); + expect(cancel).not.toHaveBeenCalled(); + }); + + it('keeps banner when enhance callback receives a failure result', async () => { + render(Page, { props: { form: null } }); + const [navCallback] = vi.mocked(beforeNavigate).mock.calls[0]; + + document + .querySelector('input[name="name"]')! + .dispatchEvent(new InputEvent('input', { bubbles: true })); + + navCallback({ cancel: vi.fn(), to: { url: new URL('http://localhost/admin/groups') } }); + 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: 'Name bereits vergeben' } }, + update: vi.fn().mockResolvedValue(undefined) + }); + + const cancel = vi.fn(); + navCallback({ cancel, to: { url: new URL('http://localhost/admin/groups') } }); + expect(cancel).toHaveBeenCalled(); + }); +}); -- 2.49.1 From ffcb901376c6e93291a40fc098407715c6e77aca Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 14 May 2026 11:53:15 +0200 Subject: [PATCH 2/4] 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(); + }); +}); -- 2.49.1 From 6fffc06c28f887ae0efb4186725f6fb748219871 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 14 May 2026 11:58:19 +0200 Subject: [PATCH 3/4] fix(test): allow extra result properties in enhance callback type Use [key: string]: unknown index signature so TS does not reject the extra fields (location, status) passed to the redirect/failure result in the spec helpers. Co-Authored-By: Claude Sonnet 4.6 --- .../src/routes/admin/groups/new/page.svelte.spec.ts | 10 ++++++++-- .../src/routes/admin/users/new/page.svelte.spec.ts | 10 ++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/frontend/src/routes/admin/groups/new/page.svelte.spec.ts b/frontend/src/routes/admin/groups/new/page.svelte.spec.ts index 615f4587..e74499cc 100644 --- a/frontend/src/routes/admin/groups/new/page.svelte.spec.ts +++ b/frontend/src/routes/admin/groups/new/page.svelte.spec.ts @@ -82,7 +82,10 @@ describe('Admin new group page – unsaved-changes guard', () => { await expect.element(page.getByText(/ungespeicherte Änderungen/i)).toBeInTheDocument(); type SubmitFn = () => Promise< - (opts: { result: { type: string }; update: () => Promise }) => Promise + (opts: { + result: { type: string; [key: string]: unknown }; + update: () => Promise; + }) => Promise >; const innerFn = await (enhanceCaptureRef.submitFn as SubmitFn)(); await innerFn({ @@ -109,7 +112,10 @@ describe('Admin new group page – unsaved-changes guard', () => { await expect.element(page.getByText(/ungespeicherte Änderungen/i)).toBeInTheDocument(); type SubmitFn = () => Promise< - (opts: { result: { type: string }; update: () => Promise }) => Promise + (opts: { + result: { type: string; [key: string]: unknown }; + update: () => Promise; + }) => Promise >; const innerFn = await (enhanceCaptureRef.submitFn as SubmitFn)(); await innerFn({ 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 4ae16236..3c00ffdd 100644 --- a/frontend/src/routes/admin/users/new/page.svelte.spec.ts +++ b/frontend/src/routes/admin/users/new/page.svelte.spec.ts @@ -142,7 +142,10 @@ describe('Admin new user page – unsaved-changes guard', () => { await expect.element(page.getByText(/ungespeicherte Änderungen/i)).toBeInTheDocument(); type SubmitFn = () => Promise< - (opts: { result: { type: string }; update: () => Promise }) => Promise + (opts: { + result: { type: string; [key: string]: unknown }; + update: () => Promise; + }) => Promise >; const innerFn = await (enhanceCaptureRef.submitFn as SubmitFn)(); await innerFn({ @@ -169,7 +172,10 @@ describe('Admin new user page – unsaved-changes guard', () => { await expect.element(page.getByText(/ungespeicherte Änderungen/i)).toBeInTheDocument(); type SubmitFn = () => Promise< - (opts: { result: { type: string }; update: () => Promise }) => Promise + (opts: { + result: { type: string; [key: string]: unknown }; + update: () => Promise; + }) => Promise >; const innerFn = await (enhanceCaptureRef.submitFn as SubmitFn)(); await innerFn({ -- 2.49.1 From 2ca8428be4b0a04b3188eec0216e9a769a29768e Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 14 May 2026 12:11:33 +0200 Subject: [PATCH 4/4] refactor(test): hoist SubmitFn to file-level type in unsaved-guard specs Co-Authored-By: Claude Sonnet 4.6 --- .../admin/groups/new/page.svelte.spec.ts | 19 +++++++------------ .../admin/users/new/page.svelte.spec.ts | 19 +++++++------------ 2 files changed, 14 insertions(+), 24 deletions(-) diff --git a/frontend/src/routes/admin/groups/new/page.svelte.spec.ts b/frontend/src/routes/admin/groups/new/page.svelte.spec.ts index e74499cc..a094997f 100644 --- a/frontend/src/routes/admin/groups/new/page.svelte.spec.ts +++ b/frontend/src/routes/admin/groups/new/page.svelte.spec.ts @@ -17,6 +17,13 @@ import { beforeNavigate, goto } from '$app/navigation'; afterEach(cleanup); +type SubmitFn = () => Promise< + (opts: { + result: { type: string; [key: string]: unknown }; + update: () => Promise; + }) => Promise +>; + // ─── Unsaved-changes guard ──────────────────────────────────────────────────── describe('Admin new group page – unsaved-changes guard', () => { @@ -81,12 +88,6 @@ describe('Admin new group page – unsaved-changes guard', () => { navCallback({ cancel: vi.fn(), to: { url: new URL('http://localhost/admin/groups') } }); await expect.element(page.getByText(/ungespeicherte Änderungen/i)).toBeInTheDocument(); - type SubmitFn = () => Promise< - (opts: { - result: { type: string; [key: string]: unknown }; - update: () => Promise; - }) => Promise - >; const innerFn = await (enhanceCaptureRef.submitFn as SubmitFn)(); await innerFn({ result: { type: 'redirect', location: '/admin/groups', status: 303 }, @@ -111,12 +112,6 @@ describe('Admin new group page – unsaved-changes guard', () => { navCallback({ cancel: vi.fn(), to: { url: new URL('http://localhost/admin/groups') } }); await expect.element(page.getByText(/ungespeicherte Änderungen/i)).toBeInTheDocument(); - type SubmitFn = () => Promise< - (opts: { - result: { type: string; [key: string]: unknown }; - update: () => Promise; - }) => Promise - >; const innerFn = await (enhanceCaptureRef.submitFn as SubmitFn)(); await innerFn({ result: { type: 'failure', status: 400, data: { error: 'Name bereits vergeben' } }, 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 3c00ffdd..84efe99e 100644 --- a/frontend/src/routes/admin/users/new/page.svelte.spec.ts +++ b/frontend/src/routes/admin/users/new/page.svelte.spec.ts @@ -30,6 +30,13 @@ const baseData = { afterEach(cleanup); +type SubmitFn = () => Promise< + (opts: { + result: { type: string; [key: string]: unknown }; + update: () => Promise; + }) => Promise +>; + // ─── Rendering ──────────────────────────────────────────────────────────────── describe('Admin new user page – rendering', () => { @@ -141,12 +148,6 @@ describe('Admin new user page – unsaved-changes guard', () => { 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; [key: string]: unknown }; - update: () => Promise; - }) => Promise - >; const innerFn = await (enhanceCaptureRef.submitFn as SubmitFn)(); await innerFn({ result: { type: 'redirect', location: '/admin/users', status: 303 }, @@ -171,12 +172,6 @@ describe('Admin new user page – unsaved-changes guard', () => { 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; [key: string]: unknown }; - update: () => Promise; - }) => Promise - >; const innerFn = await (enhanceCaptureRef.submitFn as SubmitFn)(); await innerFn({ result: { type: 'failure', status: 400, data: { error: 'E-Mail bereits vergeben' } }, -- 2.49.1