fix(#248): add confirmation dialog before tag delete
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m29s
CI / Backend Unit Tests (push) Failing after 2m43s
CI / Unit & Component Tests (pull_request) Failing after 2m36s
CI / Backend Unit Tests (pull_request) Failing after 2m34s

TagDeleteGuard now calls confirm() (admin_tag_delete_confirm) before
submitting — same pattern as document delete. Button changed to type=button
with an async handler; page.svelte.spec.ts updated to pass ConfirmService
context so TagDeleteGuard can initialise inside the page render.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-17 09:39:33 +02:00
parent 47d57b96c8
commit 4442b25a7a
3 changed files with 92 additions and 26 deletions

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { m } from '$lib/paraglide/messages.js';
import { getConfirmService } from '$lib/services/confirm.svelte.js';
type FlatTag = {
id: string;
@@ -17,9 +18,17 @@ interface Props {
let { tag, allTags }: Props = $props();
let deleteForm: HTMLFormElement;
const { confirm } = getConfirmService();
let selectedMode: 'single' | 'subtree' | null = $state(null);
const canDelete = $derived(selectedMode !== null);
async function handleDelete() {
const ok = await confirm({ title: m.admin_tag_delete_confirm(), destructive: true });
if (ok) deleteForm.requestSubmit();
}
// Count all descendants (recursive walk through allTags)
const descendantCount = $derived.by(() => {
let count = 0;
@@ -87,12 +96,13 @@ const descendantCount = $derived.by(() => {
</div>
<!-- Confirm form -->
<form method="POST" action="?/delete" use:enhance>
<form bind:this={deleteForm} method="POST" action="?/delete" use:enhance>
<input type="hidden" name="deleteMode" value={selectedMode ?? ''} />
<button
type="submit"
type="button"
data-testid="delete-submit-btn"
disabled={!canDelete}
onclick={handleDelete}
class="rounded-sm bg-danger px-4 py-2 font-sans text-xs font-bold tracking-widest text-danger-fg uppercase transition-opacity hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-40"
>
{selectedMode === 'subtree' ? m.admin_tag_delete_subtree_confirm_btn() : m.btn_delete()}

View File

@@ -2,11 +2,21 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import TagDeleteGuard from './TagDeleteGuard.svelte';
import { createConfirmService, CONFIRM_KEY } from '$lib/services/confirm.svelte.js';
vi.mock('$app/forms', () => ({ enhance: () => () => {} }));
afterEach(cleanup);
function renderWithConfirm(props = { tag, allTags }) {
const service = createConfirmService();
const result = render(TagDeleteGuard, {
props,
context: new Map([[CONFIRM_KEY, service]])
});
return { ...result, service };
}
const tag = { id: 't1', name: 'Familie', documentCount: 3 };
const allTags = [
{ id: 't1', name: 'Familie', documentCount: 3 },
@@ -16,7 +26,7 @@ const allTags = [
describe('TagDeleteGuard', () => {
it('renders two radio options (single and subtree)', async () => {
render(TagDeleteGuard, { tag, allTags });
renderWithConfirm();
await expect.element(page.getByRole('radio', { name: /Nur dieses/i })).toBeInTheDocument();
await expect
.element(page.getByRole('radio', { name: /Gesamten Teilbaum/i }))
@@ -24,24 +34,24 @@ describe('TagDeleteGuard', () => {
});
it('delete button is disabled initially', async () => {
render(TagDeleteGuard, { tag, allTags });
renderWithConfirm();
await expect.element(page.getByTestId('delete-submit-btn')).toBeDisabled();
});
it('delete button is enabled after selecting single radio', async () => {
render(TagDeleteGuard, { tag, allTags });
renderWithConfirm();
await page.getByRole('radio', { name: /Nur dieses/i }).click();
await expect.element(page.getByTestId('delete-submit-btn')).not.toBeDisabled();
});
it('delete button is enabled after selecting subtree radio', async () => {
render(TagDeleteGuard, { tag, allTags });
renderWithConfirm();
await page.getByRole('radio', { name: /Gesamten Teilbaum/i }).click();
await expect.element(page.getByTestId('delete-submit-btn')).not.toBeDisabled();
});
it('delete button shows subtree-specific text when subtree mode is selected', async () => {
render(TagDeleteGuard, { tag, allTags });
renderWithConfirm();
await page.getByRole('radio', { name: /Gesamten Teilbaum/i }).click();
await expect
.element(page.getByTestId('delete-submit-btn'))
@@ -49,13 +59,52 @@ describe('TagDeleteGuard', () => {
});
it('shows descendant count in impact summary', async () => {
render(TagDeleteGuard, { tag, allTags });
renderWithConfirm();
// tag has 2 descendants (t2 and t3)
await expect.element(page.getByText(/2 Untergeordnete/)).toBeInTheDocument();
});
it('shows document count in impact summary', async () => {
render(TagDeleteGuard, { tag, allTags });
renderWithConfirm();
await expect.element(page.getByText(/3 Dokument/)).toBeInTheDocument();
});
});
describe('TagDeleteGuard confirmation dialog', () => {
it('opens confirm dialog when delete button is clicked', async () => {
const { service } = renderWithConfirm();
await page.getByRole('radio', { name: /Nur dieses/i }).click();
await page.getByTestId('delete-submit-btn').click();
await vi.waitFor(() => expect(service.options).not.toBeNull());
expect(service.options?.destructive).toBe(true);
service.settle(false);
});
it('submits the form when user confirms', async () => {
const { service, container } = renderWithConfirm();
const form = container.querySelector('form')!;
const requestSubmit = vi.spyOn(form, 'requestSubmit').mockImplementation(() => {});
await page.getByRole('radio', { name: /Nur dieses/i }).click();
await page.getByTestId('delete-submit-btn').click();
await vi.waitFor(() => expect(service.options).not.toBeNull());
service.settle(true);
await vi.waitFor(() => expect(service.options).toBeNull());
expect(requestSubmit).toHaveBeenCalledOnce();
});
it('does not submit when user cancels the dialog', async () => {
const { service, container } = renderWithConfirm();
const form = container.querySelector('form')!;
const requestSubmit = vi.spyOn(form, 'requestSubmit').mockImplementation(() => {});
await page.getByRole('radio', { name: /Nur dieses/i }).click();
await page.getByTestId('delete-submit-btn').click();
await vi.waitFor(() => expect(service.options).not.toBeNull());
service.settle(false);
await vi.waitFor(() => expect(service.options).toBeNull());
expect(requestSubmit).not.toHaveBeenCalled();
});
});

View File

@@ -2,6 +2,7 @@ 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';
import { createConfirmService, CONFIRM_KEY } from '$lib/services/confirm.svelte.js';
vi.mock('$app/forms', () => ({ enhance: () => () => {} }));
vi.mock('$app/navigation', () => ({
@@ -32,24 +33,30 @@ const baseData = {
}[]
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function renderPage(props: { data: any; form: null }) {
const service = createConfirmService();
return render(Page, { props, context: new Map([[CONFIRM_KEY, service]]) });
}
afterEach(cleanup);
// ─── Rendering ────────────────────────────────────────────────────────────────
describe('Admin edit tag page rendering', () => {
it('renders the heading with tag name', async () => {
render(Page, { data: baseData, form: null });
renderPage({ 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 });
renderPage({ data: baseData, form: null });
const input = document.querySelector<HTMLInputElement>('input[name="name"]');
expect(input?.value).toBe('Familie');
});
it('renders the cancel link pointing to /admin/tags', async () => {
render(Page, { data: baseData, form: null });
renderPage({ data: baseData, form: null });
await expect
.element(page.getByRole('link', { name: /Abbrechen/i }))
.toHaveAttribute('href', '/admin/tags');
@@ -62,12 +69,12 @@ 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 });
renderPage({ 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 });
renderPage({ data: baseData, form: null });
const [callback] = vi.mocked(beforeNavigate).mock.calls[0];
document
@@ -82,7 +89,7 @@ describe('Admin edit tag page unsaved-changes guard', () => {
});
it('does not cancel navigation when form is clean', async () => {
render(Page, { data: baseData, form: null });
renderPage({ data: baseData, form: null });
const [callback] = vi.mocked(beforeNavigate).mock.calls[0];
const cancel = vi.fn();
@@ -92,7 +99,7 @@ describe('Admin edit tag page unsaved-changes guard', () => {
});
it('discard button calls goto with the target URL', async () => {
render(Page, { data: baseData, form: null });
renderPage({ data: baseData, form: null });
const [callback] = vi.mocked(beforeNavigate).mock.calls[0];
document
@@ -111,7 +118,7 @@ describe('Admin edit tag page unsaved-changes guard', () => {
describe('Admin edit tag page parent selector', () => {
it('renders a TagParentPicker combobox', async () => {
render(Page, { data: baseData, form: null });
renderPage({ data: baseData, form: null });
await expect
.element(page.getByRole('combobox', { name: /Übergeordnetes Schlagwort/i }))
.toBeInTheDocument();
@@ -122,7 +129,7 @@ describe('Admin edit tag page parent selector', () => {
describe('Admin edit tag page color picker', () => {
it('renders color picker when tag has no parent', async () => {
render(Page, {
renderPage({
data: { tag: { id: 't1', name: 'Familie', parentId: undefined, documentCount: 0 }, tags: [] },
form: null
});
@@ -130,7 +137,7 @@ describe('Admin edit tag page color picker', () => {
});
it('hides color picker when tag already has a parent', async () => {
render(Page, {
renderPage({
data: {
tag: { id: 't1', name: 'Familie', parentId: 't2', documentCount: 0 },
tags: [{ id: 't2', name: 'Reise', documentCount: 0 }]
@@ -141,7 +148,7 @@ describe('Admin edit tag page color picker', () => {
});
it('pre-selects the current tag color in the color picker', async () => {
render(Page, {
renderPage({
data: { tag: { id: 't1', name: 'Familie', color: 'sage', documentCount: 0 }, tags: [] },
form: null
});
@@ -154,12 +161,12 @@ describe('Admin edit tag page color picker', () => {
describe('Admin edit tag page merge success banner', () => {
it('shows merge success banner when data.mergeSuccess is true', async () => {
render(Page, { data: { ...baseData, mergeSuccess: true }, form: null });
renderPage({ data: { ...baseData, mergeSuccess: true }, form: null });
await expect.element(page.getByText(/Erfolgreich zusammengeführt/i)).toBeInTheDocument();
});
it('does not show merge success banner when data.mergeSuccess is false', async () => {
render(Page, { data: { ...baseData, mergeSuccess: false }, form: null });
renderPage({ data: { ...baseData, mergeSuccess: false }, form: null });
await expect.element(page.getByText(/Erfolgreich zusammengeführt/i)).not.toBeInTheDocument();
});
});
@@ -168,7 +175,7 @@ describe('Admin edit tag page merge success banner', () => {
describe('Admin edit tag page new components', () => {
it('renders TagAncestry nav when tag has a parent', async () => {
const { container } = render(Page, {
const { container } = renderPage({
data: {
tag: { id: 't2', name: 'Kind', parentId: 't1', documentCount: 0 },
tags: [
@@ -182,17 +189,17 @@ describe('Admin edit tag page new components', () => {
});
it('does not render TagAncestry nav for root tag', async () => {
const { container } = render(Page, { data: baseData, form: null });
const { container } = renderPage({ data: baseData, form: null });
expect(container.querySelector('nav')).toBeFalsy();
});
it('renders TagMergeZone with merge heading', async () => {
render(Page, { data: baseData, form: null });
renderPage({ data: baseData, form: null });
await expect.element(page.getByText(/Zusammenführen/i)).toBeInTheDocument();
});
it('renders TagDeleteGuard with two radio options', async () => {
render(Page, { data: baseData, form: null });
renderPage({ data: baseData, form: null });
const radios = document.querySelectorAll<HTMLInputElement>('input[type="radio"]');
expect(radios.length).toBe(2);
});