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(); + }); +});