diff --git a/frontend/messages/de.json b/frontend/messages/de.json index cd4ad3de..9b24a14c 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -160,6 +160,7 @@ "admin_tags_select_prompt": "W\u00e4hle ein Schlagwort aus der Liste.", "admin_tag_edit_heading": "Schlagwort: {name}", "admin_tag_updated": "Schlagwort umbenannt.", + "admin_unsaved_warning": "Du hast ungespeicherte Änderungen – speichere oder verwerfe, bevor du wechselst.", "admin_btn_edit_tag_label": "Schlagwort bearbeiten", "admin_tag_delete_confirm": "Wirklich löschen? Das Schlagwort wird aus allen Dokumenten entfernt.", "admin_btn_delete_tag_label": "Schlagwort löschen", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 1484b937..eda45cda 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -160,6 +160,7 @@ "admin_tags_select_prompt": "Select a tag from the list.", "admin_tag_edit_heading": "Tag: {name}", "admin_tag_updated": "Tag renamed.", + "admin_unsaved_warning": "You have unsaved changes — save or discard before switching.", "admin_btn_edit_tag_label": "Edit tag", "admin_tag_delete_confirm": "Really delete? The tag will be removed from all documents.", "admin_btn_delete_tag_label": "Delete tag", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 381ba174..49263510 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -160,6 +160,7 @@ "admin_tags_select_prompt": "Selecciona una etiqueta de la lista.", "admin_tag_edit_heading": "Etiqueta: {name}", "admin_tag_updated": "Etiqueta renombrada.", + "admin_unsaved_warning": "Tienes cambios sin guardar — guarda o descarta antes de cambiar.", "admin_btn_edit_tag_label": "Editar etiqueta", "admin_tag_delete_confirm": "¿Realmente eliminar? La etiqueta se eliminará de todos los documentos.", "admin_btn_delete_tag_label": "Eliminar etiqueta", diff --git a/frontend/src/routes/admin/groups/[id]/+page.svelte b/frontend/src/routes/admin/groups/[id]/+page.svelte index 377617d3..3705ea67 100644 --- a/frontend/src/routes/admin/groups/[id]/+page.svelte +++ b/frontend/src/routes/admin/groups/[id]/+page.svelte @@ -1,9 +1,29 @@
@@ -25,13 +38,40 @@ let { form } = $props();
+ {#if showUnsavedWarning} +
+ {m.admin_unsaved_warning()} + +
+ {/if} {#if form?.error}
{form.error}
{/if} -
+ { + isDirty = true; + showUnsavedWarning = false; + }} + class="space-y-5" + >

diff --git a/frontend/src/routes/admin/tags/[id]/+page.svelte b/frontend/src/routes/admin/tags/[id]/+page.svelte index 078b2601..77d4af1e 100644 --- a/frontend/src/routes/admin/tags/[id]/+page.svelte +++ b/frontend/src/routes/admin/tags/[id]/+page.svelte @@ -1,11 +1,31 @@
@@ -18,6 +38,24 @@ const deleteEnabled = $derived(deleteConfirmName === data.tag.name);
+ {#if showUnsavedWarning} +
+ {m.admin_unsaved_warning()} + +
+ {/if} {#if form?.success}
{m.admin_tag_updated()} @@ -30,7 +68,17 @@ const deleteEnabled = $derived(deleteConfirmName === data.tag.name); {/if} - + { + isDirty = true; + showUnsavedWarning = false; + }} + class="mb-5" + >

{m.admin_col_name()} diff --git a/frontend/src/routes/admin/tags/[id]/page.svelte.spec.ts b/frontend/src/routes/admin/tags/[id]/page.svelte.spec.ts new file mode 100644 index 00000000..e327f6b8 --- /dev/null +++ b/frontend/src/routes/admin/tags/[id]/page.svelte.spec.ts @@ -0,0 +1,93 @@ +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: () => () => {} })); +vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), goto: vi.fn() })); + +import { beforeNavigate, goto } from '$app/navigation'; + +const baseTag = { id: 't1', name: 'Familie' }; +const baseData = { tag: baseTag }; + +afterEach(cleanup); + +// ─── Rendering ──────────────────────────────────────────────────────────────── + +describe('Admin edit tag page – rendering', () => { + it('renders the heading with tag name', async () => { + render(Page, { data: baseData, form: null }); + await expect.element(page.getByText(/Schlagwort: Familie/i)).toBeInTheDocument(); + }); + + it('pre-fills the name input', async () => { + render(Page, { data: baseData, form: null }); + const input = document.querySelector('input[name="name"]'); + expect(input?.value).toBe('Familie'); + }); + + it('renders the cancel link pointing to /admin/tags', async () => { + render(Page, { data: baseData, form: null }); + await expect + .element(page.getByRole('link', { name: /Abbrechen/i })) + .toHaveAttribute('href', '/admin/tags'); + }); + + it('delete button is disabled until tag name is typed in confirm field', async () => { + render(Page, { data: baseData, form: null }); + const deleteBtn = document.querySelector('button[type="submit"]'); + expect(deleteBtn?.disabled).toBe(true); + }); +}); + +// ─── Unsaved-changes guard ──────────────────────────────────────────────────── + +describe('Admin edit tag page – unsaved-changes guard', () => { + beforeEach(() => vi.clearAllMocks()); + + 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 warning when rename form is dirty', async () => { + render(Page, { data: baseData, 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/tags/t2') } }); + + 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/tags/t2') } }); + + 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="name"]')! + .dispatchEvent(new InputEvent('input', { bubbles: true })); + + callback({ cancel: vi.fn(), to: { url: new URL('http://localhost/admin/tags/t2') } }); + + await page.getByRole('button', { name: /verwerfen/i }).click(); + + expect(vi.mocked(goto)).toHaveBeenCalledWith('http://localhost/admin/tags/t2'); + }); +}); diff --git a/frontend/src/routes/admin/users/[id]/+page.svelte b/frontend/src/routes/admin/users/[id]/+page.svelte index 03ac297a..019f81ed 100644 --- a/frontend/src/routes/admin/users/[id]/+page.svelte +++ b/frontend/src/routes/admin/users/[id]/+page.svelte @@ -1,5 +1,6 @@
@@ -39,6 +59,24 @@ const selectedGroupIds = $derived(data.editUser.groups?.map((g: { id: string })
+ {#if showUnsavedWarning} +
+ {m.admin_unsaved_warning()} + +
+ {/if} {#if form?.success}
{m.admin_user_updated()} @@ -50,7 +88,16 @@ const selectedGroupIds = $derived(data.editUser.groups?.map((g: { id: string })
{/if} - + { + isDirty = true; + showUnsavedWarning = false; + }} + class="space-y-5" + >

diff --git a/frontend/src/routes/admin/users/[id]/page.svelte.spec.ts b/frontend/src/routes/admin/users/[id]/page.svelte.spec.ts index 53dce8b4..0c5fdd76 100644 --- a/frontend/src/routes/admin/users/[id]/page.svelte.spec.ts +++ b/frontend/src/routes/admin/users/[id]/page.svelte.spec.ts @@ -1,9 +1,12 @@ -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: () => () => {} })); +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'] }, @@ -141,3 +144,72 @@ describe('Admin edit user page – feedback', () => { await expect.element(page.getByText(/Änderungen gespeichert/i)).not.toBeInTheDocument(); }); }); + +// ─── Unsaved-changes guard ──────────────────────────────────────────────────── + +describe('Admin edit user page – unsaved-changes guard', () => { + beforeEach(() => vi.clearAllMocks()); + + 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 warning when form is dirty', async () => { + render(Page, { data: baseData, form: null }); + const [callback] = vi.mocked(beforeNavigate).mock.calls[0]; + + document + .querySelector('input[name="firstName"]')! + .dispatchEvent(new InputEvent('input', { bubbles: true })); + + const cancel = vi.fn(); + callback({ cancel, to: { url: new URL('http://localhost/admin/users/u2') } }); + + 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/u2') } }); + + 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="firstName"]')! + .dispatchEvent(new InputEvent('input', { bubbles: true })); + + callback({ cancel: vi.fn(), to: { url: new URL('http://localhost/admin/users/u2') } }); + + await page.getByRole('button', { name: /verwerfen/i }).click(); + + expect(vi.mocked(goto)).toHaveBeenCalledWith('http://localhost/admin/users/u2'); + }); + + it('clears dirty state when form saves successfully', async () => { + const { rerender } = render(Page, { data: baseData, form: null }); + const [callback] = vi.mocked(beforeNavigate).mock.calls[0]; + + document + .querySelector('input[name="firstName"]')! + .dispatchEvent(new InputEvent('input', { bubbles: true })); + + callback({ cancel: vi.fn(), to: { url: new URL('http://localhost/admin/users/u2') } }); + await expect.element(page.getByText(/ungespeicherte Änderungen/i)).toBeInTheDocument(); + + await rerender({ data: baseData, form: { success: true } }); + + const cancel = vi.fn(); + callback({ cancel, to: { url: new URL('http://localhost/admin/users/u2') } }); + expect(cancel).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/routes/admin/users/new/+page.svelte b/frontend/src/routes/admin/users/new/+page.svelte index cfb63278..30a0e68c 100644 --- a/frontend/src/routes/admin/users/new/+page.svelte +++ b/frontend/src/routes/admin/users/new/+page.svelte @@ -1,11 +1,24 @@
@@ -16,13 +29,40 @@ let { data, form } = $props();
+ {#if showUnsavedWarning} +
+ {m.admin_unsaved_warning()} + +
+ {/if} {#if form?.error}
{form.error}
{/if} - + { + isDirty = true; + showUnsavedWarning = false; + }} + class="space-y-5" + >